From 07a2b4bdfda06087cbdf8863d990a0c32f8009c3 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sat, 1 Aug 2026 23:36:46 -0400 Subject: [PATCH 01/93] ci: sync the release workflow on v2/main with main's verbatim (#1902) (#1904) main's copy of .github/workflows/main.yml carried three release fixes v2/main never took -- #1831 (least-privilege default GITHUB_TOKEN scope), #1834 (derive the npm dist-tag from the version) and #1836 (publish via npm OIDC trusted publishing). Diffing the file both directions shows v2/main has NOTHING main lacks: its only unique content is the superseded publish step, run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} which is exactly what #1836 replaced. Every other line already matched. So this is a verbatim copy of main's file rather than a hunk-by-hunk forward-port -- fewer ways to get it wrong, and it leaves the two branches byte-identical (`git diff origin/main -- .github/workflows` is now empty). Not a back-merge: only this one file is taken, so none of main's pre-swap v1 lineage enters v2/main's ancestry (see #1868). Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/main.yml | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5fdc748db..71e9ec4c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,6 +8,15 @@ on: release: types: [published] +# Default least-privilege scope for GITHUB_TOKEN. Without this, jobs inherit the +# repository's default token permissions, which are broader than any job here +# needs (CodeQL `actions/missing-workflow-permissions`). The `publish` and +# `publish-github-container-registry` jobs declare their own blocks below, which +# override this one entirely rather than adding to it — so each publish job must +# continue to list every scope it needs, including `contents: read`. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -145,6 +154,11 @@ jobs: exit 1 fi + # OIDC trusted publishing requires npm >= 11.5.1; Node 22's bundled npm is + # 10.x, which fails with ENEEDAUTH before OIDC is ever attempted. + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@^11.5.1 + - name: Install dependencies (root + all clients) run: npm install @@ -160,9 +174,30 @@ jobs: # prepack); the redundancy is intentional — each is a clean-tree rebuild # and the `prepack` one is what actually populates the published tarball, # so don't "optimize" it away. - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # + # The dist-tag is derived from the version, and passing it explicitly is + # NOT optional: `npm publish` defaults to `--tag latest` regardless of + # semver prerelease status, so publishing `2.0.0-rc.1` without this would + # point every `npx @modelcontextprotocol/inspector` at a release + # candidate. A prerelease is a hyphen after the patch component + # (`2.0.0-rc.1`); build metadata uses `+` and is not a prerelease. Done + # in shell rather than with `semver` because that package is only a + # transitive dependency here and must not be relied on in CI. + # + # There is deliberately NO `NODE_AUTH_TOKEN` here. Publishing uses npm + # OIDC trusted publishing (`id-token: write` + `environment: release`), + # which needs no token — and the repo has no `NPM_TOKEN` secret. Setting + # it from a non-existent secret writes an EMPTY `_authToken` into the + # `.npmrc` that `setup-node` generates, and npm then fails `ENEEDAUTH` + # before OIDC is ever attempted. Do not "restore" it. + run: | + VERSION="$(node -p "require('./package.json').version")" + case "$VERSION" in + *-*) NPM_TAG=next ;; + *) NPM_TAG=latest ;; + esac + echo "Publishing $VERSION under dist-tag '$NPM_TAG'" + npm publish --access public --provenance --tag "$NPM_TAG" # Build and push the multi-arch container image to GHCR on a published # release. The image installs the packed tarball (`Dockerfile`) so it ships From 943f37a40a861d099952b7f95739815057f72e1d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 5 Aug 2026 14:37:31 -0400 Subject: [PATCH 02/93] docs: make assigning a milestone the act that moves a card to Todo AGENTS.md had no rule linking a milestone to a board Status, and its two existing rules contradicted each other: every new issue was to be milestoned at create time, yet new items were to start in Incoming, with Todo reserved for work a maintainer had approved. If every issue is milestoned at birth, "has a milestone" carries no approval information and nothing records the moment of sign-off. Make assigning a milestone the approval act, giving one invariant: Incoming <=> no milestone; everything past it <=> milestoned. External reports and maintainer-filed issues then differ only in whether approval has already happened -- an outside reporter cannot set a label, milestone, or board field, so their issue arriving with none of them is normal rather than a defect to fix on arrival. - scope the "not created until five things" callout to maintainer-filed issues - split the Status bullets into the two cases - scope the milestone mandate to issues you create, stating the external exception explicitly - add a "Triaging externally-filed issues" section: the two passes, plus a gh/jq snippet that diffs open issues against the board to find unboarded ones - reword both prose descriptions of the Incoming/Todo line - reword the rubric's "Linked to a milestone" bonus as "already approved" and note it is the re-scoring case only; under the old rules it was a constant offset on every issue, shifting every band boundary down by one - update both gh recipes (#28 and #11) to default to Todo, with an Incoming variant for triage No mirror to .github/copilot-instructions.md: board and milestone mechanics are on that file's deliberately-absent list. Closes #1930 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 67 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b86db3801..4eb968610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,9 @@ 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`, 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 a maintainer files, not one an outside reporter files.** An external reporter can't set a label, a milestone, or a board field, so their issue arrives with none of them — that is normal and not a defect to fix at the moment it lands. It is brought into the system by [triage](#triaging-externally-filed-issues) instead. The two paths differ in exactly one thing: filing an issue yourself *is* approving it, so it starts in **Todo** with a milestone; an external report has not been approved by anyone yet, 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**. @@ -156,11 +158,12 @@ All work should be driven by items on the project board. - `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. +- **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 external report). 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 externally-filed issue → `Incoming`, no milestone.** Nobody has evaluated it yet, so it is not approved and gets no release bucket. See [Triaging externally-filed issues](#triaging-externally-filed-issues). Never park an unreviewed report 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 **externally-filed** issue 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. @@ -179,6 +182,32 @@ All work should be driven by items on the project board. - **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`. - If new tasks are discovered or requested during development, create issues and add them to the board. +### Triaging externally-filed issues + +An outside reporter has no board access, so their issue lands with **no label, no milestone, and no card**. That is the expected arrival state, not a backlog of defects — the issue enters the system through triage, in two distinct passes. + +**Pass 1 — sweep them onto the board (no approval implied).** Find the open issues with no card and bring each one in: + +1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)). +2. Add it to the board for that version. +3. Set Status to **`Incoming`**. +4. Set Priority with the [rubric](#setting-issue-priority) (v2 only). 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. + +Find the unboarded ones by diffing the open issues against the board: + +```sh +gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 --json number > /tmp/open.json +gh project item-list 28 --owner modelcontextprotocol --format json --limit 700 \ + | jq -r --slurpfile o /tmp/open.json \ + '[.items[]|select(.content.type=="Issue")|.content.number] as $B + | [$o[0][].number] - $B | "unboarded: \(.)"' +``` + +**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). + +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. + ## 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. @@ -219,7 +248,7 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **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 externally-filed issue being scored in 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 @@ -270,7 +299,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - 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 externally-filed issues** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging externally-filed issues](#triaging-externally-filed-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 **you** file skips Incoming entirely — filing it 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 @@ -298,7 +327,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | 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 externally-filed issue awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you filed yourself, which starts here — **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. 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. @@ -364,16 +393,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 **externally-filed** issue being swept in at triage, 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,7 +427,7 @@ 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 **you** file starts in **Todo**, an **externally-filed** one 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 | | --- | --- | @@ -408,9 +447,11 @@ Status option IDs — **last verified 2026-08-01**. 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 — an externally-filed issue awaiting review (no milestone) +# f75ad846 = Todo — an issue you filed yourself (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. From 04f1a56f934ce5144612e927b7d63025a3098aaa Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 15:40:49 -0400 Subject: [PATCH 03/93] =?UTF-8?q?docs:=20make=20triage=20runnable=20?= =?UTF-8?q?=E2=80=94=20state-based=20test,=20both-board=20sweep,=20board?= =?UTF-8?q?=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the same issue: the triage rules were clear on the happy path but three gaps meant a bare "triage new issues" would diverge from what a maintainer actually does. Define triage by STATE, not authorship. An issue needs triage when it arrives with no board card and no milestone — whoever filed it. Framing it as "externally-filed" was wrong: a maintainer opening an issue by hand in the GitHub UI lands in exactly the same state, because write access makes the board reachable, not automatic. What puts an issue in Todo is somebody performing the approval, not who owns the account. Renamed the section and reworded every dependent passage accordingly. Fix the discovery query, which diffed open issues against board #28 only. Verified against the live boards: it reports #1929 as unboarded when that issue is correctly carded on #11, so following it literally double-boards the issue — recreating a defect a past sweep introduced. Now diffs against the union of both boards, filtered by .content.repository (an org project can hold other repos' issues; #11 carries one from modelcontextprotocol/ servers), and prints each hit's destination, since an unboarded issue that already has a milestone is approved and belongs in Todo, not Incoming. Add "The board audit" — the other drift classes a single-issue rule can't catch (double-boarded, non-Issue items, statusless cards, the Incoming ⇔ milestone invariant in both directions, wrong board for label, missing version label, missing Priority), as a table of invariant + fix plus one runnable jq check that should print 0 across the board. Both snippets were executed verbatim against the live boards. Add "Recording the score". The rubric claimed its reasoning "survives in a form someone can argue with later", but the board stores only the result and is private, so nothing kept that promise. Triage now posts the axes, the bonuses claimed, and the total as a comment. Also scope pass 2 explicitly to a human — deciding what ships in which release is not inferable from a rubric — and correct the stale note about three statusless cards on #11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 141 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 117 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4eb968610..29819c3f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ All work should be driven by items on the project board. > **A v2 issue *you* create 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. > -> **This describes an issue a maintainer files, not one an outside reporter files.** An external reporter can't set a label, a milestone, or a board field, so their issue arrives with none of them — that is normal and not a defect to fix at the moment it lands. It is brought into the system by [triage](#triaging-externally-filed-issues) instead. The two paths differ in exactly one thing: filing an issue yourself *is* approving it, so it starts in **Todo** with a milestone; an external report has not been approved by anyone yet, so it starts in **Incoming** with no milestone. +> **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**. @@ -158,12 +158,12 @@ All work should be driven by items on the project board. - `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 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 external report). 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. +- **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. - **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 externally-filed issue → `Incoming`, no milestone.** Nobody has evaluated it yet, so it is not approved and gets no release bucket. See [Triaging externally-filed issues](#triaging-externally-filed-issues). Never park an unreviewed report 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. + - **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 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 **externally-filed** issue 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: +- **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. @@ -182,32 +182,106 @@ All work should be driven by items on the project board. - **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`. - If new tasks are discovered or requested during development, create issues and add them to the board. -### Triaging externally-filed issues +### Triaging unboarded issues -An outside reporter has no board access, so their issue lands with **no label, no milestone, and no card**. That is the expected arrival state, not a backlog of defects — the issue enters the system through triage, in two distinct passes. +**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. -**Pass 1 — sweep them onto the board (no approval implied).** Find the open issues with no card and bring each one in: +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)). -2. Add it to the board for that version. +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). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. +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. -Find the unboarded ones by diffing the open issues against the board: +**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 -gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 --json number > /tmp/open.json -gh project item-list 28 --owner modelcontextprotocol --format json --limit 700 \ - | jq -r --slurpfile o /tmp/open.json \ - '[.items[]|select(.content.type=="Issue")|.content.number] as $B - | [$o[0][].number] - $B | "unboarded: \(.)"' +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). +#### 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 Priority (#28) | Every board item is prioritized | Score it with the [rubric](#setting-issue-priority) | + +```sh +D=$(mktemp -d); R=modelcontextprotocol/inspector +gh issue list --repo $R --state open --limit 1000 --json number,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:{lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $I + | def own($s): [$s[].items[] | select(.content.repository==$R)]; + [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 + | def ms($n): ($I[($n|tostring)].ms // null); + def lab($n): ($I[($n|tostring)].lab // []); + def open($n): ($I[($n|tostring)] != null); + { + "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 open(.n) and ms(.n)==null) | .n], + "v1 label on #28": [$B28[] | select(lab(.n)|index("v1")) | .n], + "v2 label on #11": [$B11[] | select(lab(.n)|index("v2")) | .n], + "open, no version label":[$o[0][] | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], + "#28 open, no Priority": [$B28[] | select(.p==null and open(.n)) | .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. @@ -248,7 +322,7 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **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 — i.e. **already approved** by a maintainer. Note this is the *re-scoring* case: an externally-filed issue being scored in 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. +- 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 @@ -267,6 +341,25 @@ Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signa 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. @@ -299,7 +392,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - 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) - **On both boards, `Incoming` is the review queue for externally-filed issues** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging externally-filed issues](#triaging-externally-filed-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 **you** file skips Incoming entirely — filing it 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). + **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 @@ -327,7 +420,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | In Review | `159c8a02` | | Done | `259d6aab` | -Use **Incoming** for an externally-filed issue awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you filed yourself, which starts here — **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. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. +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. 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. @@ -403,7 +496,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c ``` -For an **externally-filed** issue being swept in at triage, the only difference is the Status option — **Incoming** (`721a3d4c`) instead of Todo — and that you do **not** set a milestone: +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') @@ -427,7 +520,7 @@ 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 the same Incoming/Todo split applies: one **you** file starts in **Todo**, an **externally-filed** one starts in **Incoming** awaiting review. 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 | | --- | --- | @@ -448,13 +541,13 @@ There is **no Priority field on this board** — the priority rubric applies to ```sh # Add a v1 issue to board #11. Swap the option id for the case you're in: -# 831820cf = Incoming — an externally-filed issue awaiting review (no milestone) -# f75ad846 = Todo — an issue you filed yourself (milestoned at create time) +# 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 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 From a9238426a6fdb3561163b1686dc5b38f47f7a582 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 16:25:45 -0400 Subject: [PATCH 04/93] =?UTF-8?q?docs:=20Done=20means=20shipped=20?= =?UTF-8?q?=E2=80=94=20remove,=20don't=20park,=20issues=20closed=20any=20o?= =?UTF-8?q?ther=20way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board card in Done asserts that the work shipped, and Done is read as the record of what a milestone actually delivered. Nothing said so, so an issue closed as a duplicate or not-planned was being moved to Done like any other close — which makes that record wrong in a way no later reader can detect: counting Done cards can no longer tell a shipped fix from a report closed as a duplicate of one. New rule: exactly two things earn a card a place in Done — its PR merged, or it is a parent whose last sub-issue closed. Every other close (duplicate, won't fix, not planned, obsolete, superseded) means nothing shipped, so the card is deleted instead. Deleting a card touches only the board; the issue keeps its labels and comments and stays searchable, so nothing is lost. Also notes that the close reason is the machine-readable form of the same distinction, and that `gh issue close --reason` cannot express `duplicate` — it accepts only completed/not planned, so duplicate must be set through the API or the web UI's "Mark as duplicate" (which also records a duplicate-of link). Adds the invariant to the board audit as a tenth check. That required the audit to read closed issues too, so its fetch moves to --state all with stateReason, `open(n)` becomes a state test rather than "present in the map", and --limit must now clear the repo's total issue count, not just the open ones. The two wrong-board label checks are scoped to open issues in the same pass — over all history they flag four long-closed cards whose labels nobody intends to rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 51 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29819c3f1..6a04eb377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,21 @@ All work should be driven by items on the project board. - **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 @@ -246,32 +261,42 @@ Sweeping in the unboarded issues is only the most visible defect class. A board | 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 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 -gh issue list --repo $R --state open --limit 1000 --json number,labels,milestone > "$D/i.json" +# --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:{lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $I + ($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 - | def ms($n): ($I[($n|tostring)].ms // null); - def lab($n): ($I[($n|tostring)].lab // []); - def open($n): ($I[($n|tostring)] != null); - { + | { "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 open(.n) and ms(.n)==null) | .n], - "v1 label on #28": [$B28[] | select(lab(.n)|index("v1")) | .n], - "v2 label on #11": [$B11[] | select(lab(.n)|index("v2")) | .n], - "open, no version label":[$o[0][] | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], - "#28 open, no Priority": [$B28[] | select(.p==null and open(.n)) | .n] + 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], + "#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])"' ``` @@ -420,7 +445,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | In Review | `159c8a02` | | Done | `259d6aab` | -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. 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. +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. From d0b683d4bfa9e313cbe11877bcb59eab1fd8a7b6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:04:02 -0400 Subject: [PATCH 05/93] docs: require a type label (bug/enhancement/documentation/chore/question) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version label says which line work belongs to; nothing said what KIND of work it is. In practice that meant reaching for bug or enhancement and pressing everything else into one of them — a migration guide and a tsup->tsdown swap both landing as "enhancement", which degrades the label to "not a bug" and makes filtering by it meaningless. Every issue created or triaged now carries exactly one of bug, enhancement, documentation, chore, or question, independent of its v1/v2 label. Adds a table of what each is and is not for, and states the anti-pattern explicitly so the two familiar labels don't absorb the other three. A PR needs no type label — it is classified through the issue it closes, the same way it is tracked through that issue's board card. Wires the requirement into the three places that would otherwise drift: the "not created until" callout, triage pass 1 (an outside reporter can set neither label, so both are applied in the same step), and the board audit, which gains an "open, no type label" check and a matching row in the invariant table. Note `chore` did not exist as a repo label and was created for this; the existing `dependencies` label is Dependabot's and scoped to PRs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a04eb377..3a4821452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ 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 *you* create 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. @@ -158,6 +158,19 @@ All work should be driven by items on the project board. - `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). +- **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. - **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. @@ -211,7 +224,7 @@ So don't check the author's permissions; check whether the work was done. Arrivi 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)). +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. @@ -260,6 +273,7 @@ Sweeping in the unboarded issues is only the most visible defect class. A board | 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`) | @@ -293,6 +307,10 @@ jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b "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) From 22da48c652d035c7071f4cfef72f5e44319c95df Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:46:10 -0400 Subject: [PATCH 06/93] fix: ship clients/web/static so the MCP Apps sandbox proxy loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clients/web/static/sandbox_proxy.html` is a committed source file, not a build artifact, and it was never listed in the root package.json "files" allowlist. `sandbox-controller.ts` reads it at runtime as `<runner dir>/../static/sandbox_proxy.html`, so it resolved fine in-repo but was absent from every published tarball — the read threw and the controller served its "Sandbox not loaded" fallback page, breaking the Apps tab for anyone running `npx @modelcontextprotocol/inspector`. v1 shipped the equivalent as `server/static` in both the root and the server workspace "files" lists; that entry has no counterpart in v2. Add `clients/web/static` to the allowlist, and assert it in pack:verify twice over — once in the tarball packlist and once on disk after install, since what the runtime needs is the path *relative to* clients/web/build rather than mere presence in the tarball. No .npmignore change is needed: clients/web/.gitignore does not list `static`, so the nested-gitignore packlist hazard that hid `build/` does not apply here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 1 + package.json | 1 + scripts/pack-and-verify.mjs | 31 ++++++++++++++++++++----------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a597b665e..5a5d7a2f7 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ 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 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 diff --git a/package.json b/package.json index d9472940e..fe55ae96a 100644 --- a/package.json +++ b/package.json @@ -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" 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)) { From 28a21c5e08c8529416ac92ec64a069310c45ee3c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:59:09 -0400 Subject: [PATCH 07/93] test: add an MCP App showcase config to reproduce and verify #1859 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mcp_app_demo` tool and `mcp_app_demo_widget` UI resource presets already existed in the preset registry, but no config wired them into a server — so there was no runnable way to reach a rendered MCP App, and therefore no way to exercise the sandbox proxy path that #1859 broke. `mcp-app-http.json` composes the two over plain streamable-HTTP. With it, the Apps tab renders a real widget and the packaging bug is reproducible on demand: without clients/web/static in the tarball the widget area shows "Sandbox not loaded: ENOENT … /clients/web/static/sandbox_proxy.html" instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 9 +++++++++ test-servers/configs/mcp-app-http.json | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 test-servers/configs/mcp-app-http.json diff --git a/README.md b/README.md index 5a5d7a2f7..05ffe096b 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| `mcp-app-http.json` | 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) | @@ -142,6 +143,14 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `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`. 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 + } +} From cf0ed4ac78398ef9e8a8f824f3e7e3a2f4af444c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 19:00:29 -0400 Subject: [PATCH 08/93] =?UTF-8?q?test:=20add=20smoke:web:app=20=E2=80=94?= =?UTF-8?q?=20drive=20a=20real=20MCP=20App=20end=20to=20end=20in=20headles?= =?UTF-8?q?s=20Chromium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smoke:web:browser` stops at first paint and never connects to a server, so everything downstream of the connect — the Apps tab, the sandbox controller, the UI-protocol bridge — was unexercised by any smoke. That is the code #1859 broke, and nothing would have caught a regression in it. smoke:web:app boots the same prod --web server, spawns the mcp-app-http.json composable server, and drives connect → open app → widget ready through one deep-link navigate. The assertion is the documented data-app-status="ready" contract, which the renderer only reports once the widget has loaded inside the sandbox iframe AND completed its bridge handshake — so a single attribute covers the proxy being served, the UI resource loading, and the handshake. Two mechanics found by running it, both silent-failure shaped: - server-composable.ts announces readiness on stderr, not stdout, so watching stdout alone times out with an empty diagnostic. Both streams are scanned. - the bound port is not the configured one — createTestServerHttp resolves via findAvailablePort(), which walks upward when the port is taken. The smoke parses the announced URL instead of assuming 3130. Scope: 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 — the two are complements, and the header comment says so. A cheap structural pre-check does assert the proxy page sits where sandbox-controller.ts resolves it, so a move/rename fails fast. Both failure modes verified by hand: with clients/web/static removed, the pre-check fires; with the pre-check bypassed, the data-app-status assertion catches it independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- .github/workflows/main.yml | 8 +- AGENTS.md | 3 +- README.md | 2 +- package.json | 3 +- scripts/smoke-web-app.mjs | 305 +++++++++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 scripts/smoke-web-app.mjs 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..a4185baaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -454,10 +454,11 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **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. - **`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). +- `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. diff --git a/README.md b/README.md index 05ffe096b..cb3f6c670 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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 smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | diff --git a/package.json b/package.json index fe55ae96a..d3467cf6d 100644 --- a/package.json +++ b/package.json @@ -64,11 +64,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", diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs new file mode 100644 index 000000000..8387476d4 --- /dev/null +++ b/scripts/smoke-web-app.mjs @@ -0,0 +1,305 @@ +#!/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 the other web smokes' ports so back-to-back runs can't collide. +const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6299"; +const TOKEN = "smoke-web-app-token"; +const APP_TOOL = "mcp_app_demo"; +// 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 }); + +async function shutdown() { + if (browser) { + try { + await browser.close(); + } catch { + // best-effort + } + browser = null; + } + 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; + child.on("exit", () => (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 (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(); + + const pageErrors = []; + page.on("pageerror", (err) => + pageErrors.push(err instanceof Error ? err.message : String(err)), + ); + + // 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) { + await fail( + `${err instanceof Error ? err.message : String(err)}${ + pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : "" + }`, + ); + } + + if (pageErrors.length > 0) { + await fail(`app logged uncaught error(s): ${pageErrors.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)); +} From 3f4f68699e4ec29ceb3a73dd993b02f954ef35ae Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:30:39 -0400 Subject: [PATCH 09/93] fix: give smoke:web:app its own port; note the App config's era in the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #1934, both comments actionable: - smoke:web:app defaulted to 6299 — the same port smoke:web uses — while its own comment claimed the port was "distinct … so back-to-back runs can't collide". The three web smokes run sequentially in `npm run smoke`, so it passed, but a slow teardown, a TIME_WAIT socket, or a parallel run would EADDRINUSE it. Moved to 6297 (smoke:web 6299, smoke:web:browser 6298) and rewrote the comment to name the actual values rather than assert distinctness. - The showcase table tells readers to connect with Protocol Era = Modern unless noted, and mcp-app-http.json is legacy-only. That was stated in the prose below but not in the row, so a reader scanning the table would try Modern first. Marked the row. Verified: smoke:web:app passes standalone on 6297, and the full `npm run smoke` chain passes with all three web smokes on distinct ports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 2 +- scripts/smoke-web-app.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb3f6c670..137927642 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | -| `mcp-app-http.json` | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | +| `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) | diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 8387476d4..e2c57b512 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -83,8 +83,10 @@ const sandboxProxyPage = join( ); const HOST = "127.0.0.1"; -// Distinct from the other web smokes' ports so back-to-back runs can't collide. -const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6299"; +// 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"; // The URL the test server announces on startup. NOT derived from the config's From 556aaca2983b9d63de01b56b85cafcdfffa642d8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:40:50 -0400 Subject: [PATCH 10/93] fix: fail smoke:web:app on async crashes, not just sync page errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's second pass (suppressed comments) caught a real gap: the smoke listened only for `pageerror`, which is the *synchronous* half of the uncaught-crash class. Its async twin — an unhandled rejection, or a failed dynamic import (this app lazy-loads chunks) — is not a `pageerror`; Chromium reports it on the console channel. So an async crash during the app-open path could leave the smoke green. smoke-web-browser.mjs already solved this; this now mirrors it exactly — same FATAL_CONSOLE pattern, same split between hard failures and diagnostics, so benign noise (a font-CDN miss, a React warning) still can't flake CI. Fatal console errors are now included both in the catch-block diagnostics and in the final assertion, which is where they were missing. Confirmed working on a passing run: a benign 409 subresource error is reported as a non-fatal diagnostic rather than failing the smoke. Full `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- scripts/smoke-web-app.mjs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index e2c57b512..261663c08 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -89,6 +89,12 @@ const HOST = "127.0.0.1"; 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 @@ -219,10 +225,19 @@ try { 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 @@ -285,15 +300,33 @@ try { 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)}${ - pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : "" + diagnostics.length + ? ` — page diagnostics: ${diagnostics.join("; ")}` + : "" }`, ); } - if (pageErrors.length > 0) { - await fail(`app logged uncaught error(s): ${pageErrors.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( From 5401e527e6878034d95274dd45fc1454255432dd Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:52:40 -0400 Subject: [PATCH 11/93] fix: handle spawn failure of the MCP test server in smoke:web:app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's third pass (suppressed comment) caught the remaining hole in the child-process handling: `spawn()` reports a failure to start via an `error` event, not `exit`. With no `error` listener Node throws it uncaught, replacing the smoke's diagnostic with a raw stack — and because `exit` never fires in that case, the readiness poll would otherwise spin for the full 30s before reporting a timeout that misattributes the cause. `close` is now listened to alongside `exit` for the same reason: it fires in cases `exit` does not. prod-web-server.mjs already documents this exact hazard for the launcher child; this brings the test-server child in line. Both new branches verified by hand: - unspawnable executable → "could not spawn the MCP test server (…): spawn /nonexistent/node-binary ENOENT", immediately, instead of an uncaught throw - missing module → "MCP test server exited early" with the loader stack, fast, with no 30s hang Full `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- scripts/smoke-web-app.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 261663c08..73ca5f03b 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -166,12 +166,25 @@ async function startMcpServer() { 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); } From 9644ac7fe8eb4324a6836faac4c52c813a94ece4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 17:05:36 -0400 Subject: [PATCH 12/93] fix: prime SSE streams so Firefox resolves the events fetch 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 flushed headers immediately and then stayed silent until there was something to report, which deadlocked `/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 hung on "Connecting..." forever with nothing in the UI, the console, or the node logs. Write an inert `:` SSE comment the instant each stream opens. Conforming parsers ignore a comment line, so this unblocks the read without inventing a wire event and needs no client-side protocol change. `X-Content-Type-Options: nosniff` was also proposed on the issue but does not fix it -- verified against Firefox 153, the fetch still never resolves with the header set and no body byte. The delay is not MIME sniffing. The priming write is deliberately ordered *after* each handler registers its consumer (the session event consumer; the file-watch subscriber plus `ensureWatcher()`). Callers treat the arrival of the stream's first bytes as proof they are subscribed, so priming first would hand out that proof across an `await` and drop an edit made in the gap. `/api/servers/events` gets the same treatment: its fetch was equally stuck on Firefox until the first real change event, which for an unedited `mcp.json` never comes. Its client-side reader counted any `\n\n` as a change, so the inert frame would have fired a spurious background re-fetch on every connection -- it now skips frames carrying no `event:`/`data:` field. The transport's own `parseSSE` already drops comment frames correctly and needed no change. Verified end to end in real Firefox against a live streamable-HTTP test server: before, stuck on "Connecting..." past 25s; after, initialize -> notifications/initialized -> tools/list, connected. Closes #1858 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- .../src/test/core/react/useServers.test.tsx | 68 ++++++++ .../mcp/remote/sse-priming.test.ts | 159 ++++++++++++++++++ core/mcp/remote/node/server.ts | 37 ++++ core/react/useServers.ts | 19 ++- 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/test/integration/mcp/remote/sse-priming.test.ts 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/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/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a0..6edb05d91 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). */ @@ -785,6 +806,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 +840,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 +2416,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/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); From f201032137772231a8f97171fc95e3859e70b53b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 16:48:48 -0400 Subject: [PATCH 13/93] fix: surface list-fetch failures instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list load that failed — a transport error, or a result the SDK's era codec rejects as invalid — reported nothing. The connect-time refresh is fired with no caller to await it (`void this.refresh()`), so its rejection became an unhandled rejection: the header said "Connected", the panel rendered an empty list indistinguishable from a server that has none, and the failed exchange showed in the Protocol panel as a clean success. Two surfaces now tell the truth: - ManagedListState records the failure as observable state (`getError` + an `errorChange` event) and still re-throws, so App's auth-recovery wrapper keeps working. The four `useManaged*` hooks expose it via a shared `useManagedListError`, and the Tools/Prompts/Resources sidebars render a `ListLoadError` alert with the reason and a Retry. - A response the client refused is attributed back to its Protocol entry (`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`), which now renders an Error status and a "Rejected by the Inspector" alert rather than the green resultType badge alone. The SDK gives no request id with a decode failure, so the id is recovered by correlation; see the comment on `markResponseRejected` for why that is exact. Also stops `listAllTools` from dropping a failing excluded-tools walk on the floor — it stays non-fatal but is logged. Closes #1953 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- clients/web/src/App.tsx | 16 ++- .../ListLoadError/ListLoadError.stories.tsx | 48 ++++++++ .../ListLoadError/ListLoadError.test.tsx | 51 +++++++++ .../elements/ListLoadError/ListLoadError.tsx | 65 +++++++++++ .../PromptControls/PromptControls.test.tsx | 25 +++++ .../groups/PromptControls/PromptControls.tsx | 8 ++ .../ProtocolEntry/ProtocolEntry.test.tsx | 54 +++++++++ .../groups/ProtocolEntry/ProtocolEntry.tsx | 29 ++++- .../ResourceControls.test.tsx | 25 +++++ .../ResourceControls/ResourceControls.tsx | 12 ++ .../groups/ToolControls/ToolControls.test.tsx | 25 +++++ .../groups/ToolControls/ToolControls.tsx | 8 ++ .../screens/PromptsScreen/PromptsScreen.tsx | 4 + .../ResourcesScreen/ResourcesScreen.tsx | 4 + .../screens/ToolsScreen/ToolsScreen.tsx | 4 + .../views/InspectorView/InspectorView.tsx | 17 +++ .../mcp/state/managedPromptsState.test.ts | 16 +++ .../managedResourceTemplatesState.test.ts | 16 +++ .../mcp/state/managedResourcesState.test.ts | 16 +++ .../core/mcp/state/managedToolsState.test.ts | 104 ++++++++++++++++++ .../core/mcp/state/messageLogState.test.ts | 65 +++++++++++ .../core/react/useManagedListError.test.tsx | 87 +++++++++++++++ .../inspectorClient-response-rejected.test.ts | 95 ++++++++++++++++ core/mcp/__tests__/fakeInspectorClient.ts | 4 + core/mcp/inspectorClient.ts | 57 +++++++++- core/mcp/inspectorClientEventTarget.ts | 9 ++ core/mcp/inspectorClientProtocol.ts | 9 ++ core/mcp/state/managedListState.ts | 77 ++++++++++++- core/mcp/state/managedPromptsState.ts | 11 +- .../state/managedResourceTemplatesState.ts | 9 +- core/mcp/state/managedResourcesState.ts | 11 +- core/mcp/state/managedToolsState.ts | 11 +- core/mcp/state/messageLogState.ts | 27 +++++ core/mcp/types.ts | 8 ++ core/react/useManagedListError.ts | 57 ++++++++++ core/react/useManagedPrompts.ts | 11 +- core/react/useManagedResourceTemplates.ts | 11 +- core/react/useManagedResources.ts | 11 +- core/react/useManagedTools.ts | 11 +- 39 files changed, 1080 insertions(+), 48 deletions(-) create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.tsx create mode 100644 clients/web/src/test/core/react/useManagedListError.test.tsx create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts create mode 100644 core/react/useManagedListError.ts 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/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..0408f0b56 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -711,3 +711,57 @@ 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(); + }); + + 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..d5819d3de 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", @@ -213,6 +223,9 @@ function extractStatus( if (entry.direction !== "request") return "none"; if (!entry.response) return "pending"; if ("error" in entry.response) return "error"; + // The server answered with a valid result the CLIENT then refused (#1953). + // The call failed, so the entry must not read as a success. + if (entry.clientError) return "error"; return "success"; } @@ -330,11 +343,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 +445,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..8d4e95900 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, @@ -66,6 +67,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 +115,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + loadError, pagination, onSearchChange, onOpenSectionsChange, @@ -234,6 +241,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/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index c586e12c7..1ce40d1fa 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -183,4 +183,29 @@ 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); + }); + + 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..1806b0c31 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; @@ -109,6 +115,7 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + loadError, pagination, onSearchChange, onSelectTool, @@ -146,6 +153,7 @@ export function ToolControls({ } /> <ListPaginationControls {...pagination} /> + <ListLoadError error={loadError} what="tools" onRetry={onRefreshList} /> <SidebarScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredTools.map((tool) => ( 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/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index 17c1113f1..fe33943eb 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -285,6 +285,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 Error("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..bc865d7a4 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -266,6 +266,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 Error("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..1af9d8e72 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -292,6 +292,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 Error("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..8e9e45db6 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -412,6 +412,110 @@ 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"); + }); + + it("attributes the failure to its Protocol entry", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + boom.message, + ); + }); + + 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..7863fe320 --- /dev/null +++ b/clients/web/src/test/core/react/useManagedListError.test.tsx @@ -0,0 +1,87 @@ +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); + }); +}); 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/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..b0c14f94b 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). */ @@ -786,6 +793,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 +809,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(), @@ -3080,8 +3103,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 +3127,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; 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/state/managedListState.ts b/core/mcp/state/managedListState.ts index 4f6160e15..62b9fdfac 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -29,12 +29,29 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; -/** Every managed-list event map carries the list-changed indicator event. */ +/** + * 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 +98,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 +134,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 +159,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 +216,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 +267,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 +290,34 @@ 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. + 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..52840af8a 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. */ diff --git a/core/react/useManagedListError.ts b/core/react/useManagedListError.ts new file mode 100644 index 000000000..22fe307f7 --- /dev/null +++ b/core/react/useManagedListError.ts @@ -0,0 +1,57 @@ +import { useState, useEffect } 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. + */ +export function useManagedListError( + state: ManagedListErrorSource | null, +): Error | null { + const [error, setError] = useState<Error | null>(state?.getError() ?? null); + + useEffect(() => { + if (!state) { + setError(null); + return; + } + setError(state.getError()); + const onErrorChange = ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => { + setError(event.detail); + }; + state.addEventListener("errorChange", onErrorChange); + return () => { + state.removeEventListener("errorChange", onErrorChange); + }; + }, [state]); + + return error; +} 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 }; } From 1e427c05f3b4c032889045930e8a920153af555e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:11:34 -0400 Subject: [PATCH 14/93] fix: mark a rejected standalone response entry as an error too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractStatus returned none for any non-request entry, so a clientError annotation that landed on a standalone response frame — the fallback messageLogState uses when no request entry was there to fold into, e.g. a trimmed log or a reconnect boundary — rendered with no status badge at all. The clientError check now runs before the request-only lifecycle. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../ProtocolEntry/ProtocolEntry.test.tsx | 35 +++++++++++++++++++ .../groups/ProtocolEntry/ProtocolEntry.tsx | 10 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 0408f0b56..45d658c42 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -756,6 +756,41 @@ describe("ProtocolEntry — client-rejected response", () => { 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 />, diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index d5819d3de..73edda6cd 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -220,12 +220,16 @@ 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"; - // The server answered with a valid result the CLIENT then refused (#1953). - // The call failed, so the entry must not read as a success. - if (entry.clientError) return "error"; return "success"; } From fad848037617195fb86a7ec88eb8582816b5a48c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:26:36 -0400 Subject: [PATCH 15/93] fix: only attribute a decode rejection to a Protocol entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markResponseRejected was called for ANY refresh failure. The id it recovers is "the last response received for this method", which is the failing exchange only when a response actually arrived and was refused while decoding. A transport drop, a timeout, or an abort produces no response frame at all — the last-answered id then still points at an EARLIER successful call, so marking it stamped "Rejected by the Inspector" onto an exchange that worked. That is the same class of lie this issue exists to remove. A server-sent JSON-RPC error is excluded for a different reason: the id would be right, but the failure is the server's and its entry already renders as an error from the error frame. Gated on SdkErrorCode.InvalidResult / UnsupportedResultType — decisions the client made about a frame in hand. The failure is still recorded as list state either way; only the Protocol attribution is gated. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../mcp/state/managedPromptsState.test.ts | 3 +- .../managedResourceTemplatesState.test.ts | 3 +- .../mcp/state/managedResourcesState.test.ts | 3 +- .../core/mcp/state/managedToolsState.test.ts | 77 +++++++++++++++++-- core/mcp/state/managedListState.ts | 46 +++++++++-- 5 files changed, 117 insertions(+), 15 deletions(-) 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 fe33943eb..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"; @@ -289,7 +290,7 @@ describe("ManagedPromptsState", () => { // 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 Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllPrompts.mockRejectedValueOnce(boom); 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 bc865d7a4..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"; @@ -270,7 +271,7 @@ describe("ManagedResourceTemplatesState", () => { // 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 Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllResourceTemplates.mockRejectedValueOnce(boom); 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 1af9d8e72..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"; @@ -296,7 +297,7 @@ describe("ManagedResourcesState", () => { // 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 Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllResources.mockRejectedValueOnce(boom); 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 8e9e45db6..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"; @@ -453,14 +454,76 @@ describe("ManagedToolsState", () => { expect(state.getError()?.message).toBe("just a string"); }); - it("attributes the failure to its Protocol entry", async () => { - client.setStatus("connected"); - client.listAllTools.mockRejectedValueOnce(boom); - await expect(state.refresh()).rejects.toThrow(boom); - expect(client.markResponseRejected).toHaveBeenCalledWith( - "tools/list", - boom.message, + // 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 () => { diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index 62b9fdfac..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,6 +30,38 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; +/** + * 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). @@ -310,11 +343,14 @@ export abstract class ManagedListState< // 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. - this.client?.markResponseRejected?.( - this.config.listMethod, - error.message, - ); + // 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); From a6f233eb6a95a6d95c5bb2e0f929ace96ee41a3b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:41:27 -0400 Subject: [PATCH 16/93] fix: read the list error via useSyncExternalStore; clear correlation maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from Copilot's review of #1953. useManagedListError re-synced React state from the `state` prop inside a useEffect, which renders one frame carrying the PREVIOUS store's error after `state` changes (switching servers) before the effect corrects it — the pattern AGENTS.md forbids. useSyncExternalStore reads the snapshot during render, so a store swap lands in the same frame, and it also closes the window where an error recorded between render and subscribe was missed. The snapshot returns the stored Error instance (or null), so it is referentially stable as the API requires. The `markResponseRejected` correlation maps were never cleared. Ids of requests that never got a response — a timeout, a dropped connection — are the only entries trackResponse can't remove, so they accumulated across reconnects. Cleared on the start-clean connect path, per the convention documented on resetSessionState (one route out, `onerror` with no `onclose`, tears down nothing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../core/react/useManagedListError.test.tsx | 25 ++++++++++ core/mcp/inspectorClient.ts | 11 +++++ core/react/useManagedListError.ts | 49 ++++++++++++------- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/core/react/useManagedListError.test.tsx b/clients/web/src/test/core/react/useManagedListError.test.tsx index 7863fe320..1eac0afde 100644 --- a/clients/web/src/test/core/react/useManagedListError.test.tsx +++ b/clients/web/src/test/core/react/useManagedListError.test.tsx @@ -84,4 +84,29 @@ describe("useManagedListError", () => { 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/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0c14f94b..8e2684778 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1581,6 +1581,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")); } diff --git a/core/react/useManagedListError.ts b/core/react/useManagedListError.ts index 22fe307f7..64438e523 100644 --- a/core/react/useManagedListError.ts +++ b/core/react/useManagedListError.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { ManagedListEventMap } from "../mcp/state/managedListState.js"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; @@ -30,28 +30,39 @@ export interface ManagedListErrorSource { * 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 [error, setError] = useState<Error | null>(state?.getError() ?? null); + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!state) return () => {}; + const listener = () => onStoreChange(); + state.addEventListener("errorChange", listener); + return () => { + state.removeEventListener("errorChange", listener); + }; + }, + [state], + ); - useEffect(() => { - if (!state) { - setError(null); - return; - } - setError(state.getError()); - const onErrorChange = ( - event: TypedEventGeneric<ManagedListEventMap, "errorChange">, - ) => { - setError(event.detail); - }; - state.addEventListener("errorChange", onErrorChange); - return () => { - state.removeEventListener("errorChange", onErrorChange); - }; - }, [state]); + const getSnapshot = useCallback(() => state?.getError() ?? null, [state]); - return error; + // 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); } From bb219dc0014bd68b92a7e6a0c0242153a416b80a Mon Sep 17 00:00:00 2001 From: Cliff Hall <olahungerford@gmail.com> Date: Sat, 8 Aug 2026 20:30:16 -0400 Subject: [PATCH 17/93] fix: construct AsyncEntry inside the try so keychain degradation engages (#1848) (#1945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: construct AsyncEntry inside the try so keychain degradation engages `KeyringSecretStore` built its `AsyncEntry` outside the `try` in `get`, `set`, and `delete`. `AsyncEntry::new` performs the platform-store setup (on Linux, the Secret Service connect with a keyutils fallback) and throws when no backend is reachable, so the documented degradation contract never engaged for a construction-time failure: the raw keyring error escaped, 500ing every `GET /api/servers` on a box without a Secret Service (the published container, which has no D-Bus session). `expectedSecretFields` always includes the OAuth slot, so `rehydrateConfig` constructs an entry for every server — the default seeded catalog was enough to 500 the first list load, before any server was added. And because the escaping error wasn't a `KeychainUnavailableError`, it bypassed both the routes' 503 translation and the `migratePlaintextSecrets` skip branch, yielding a generic 500 instead of the actionable message. Moving construction inside the existing `try` restores the contract for that failure mode: `get` returns null, `delete` no-ops, and `set` is the only operation that throws — as `KeychainUnavailableError`, which is what the 503 translation and the migration skip both match on. The test stub's constructor could not fail, which is why the coverage gate never saw the gap. Adds a `constructorThrows` hook alongside the existing method-level `failures` flags and covers all three methods plus `deleteAllForServer` under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F * test: pin why the OAuth secret field is unconditional `expectedSecretFields` always includes the OAuth slot, and #1848's report frames that as why the 500 fired so broadly — which invites a follow-up that skips the keychain read for servers with "no OAuth config". That change would lose data. `extractSecretsFromStored` deletes the `oauth` block outright when `clientSecret` was its only property, so such a server carries NO marker on disk that a secret exists — the keychain is the sole record, and the unconditional slot is what finds it again. Gating the read on a disk-visible `oauth` block would silently stop rehydrating exactly that shape. The existing tests do fail if the slot is made conditional (verified by applying the change: 4 red), but only under names that explain nothing about the consequence — `always lists the OAuth slot first` reads like a tautology worth updating rather than a trap. Add a round-trip case that states the consequence, so the next reader sees the field is load-bearing rather than defensive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../web/src/test/core/mcp/serverList.test.ts | 30 ++++++++++ .../auth/node/secret-store.test.ts | 56 +++++++++++++++++++ core/auth/node/secret-store.ts | 41 ++++++++------ 3 files changed, 111 insertions(+), 16 deletions(-) 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/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index f118081c3..29130bfa2 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 @@ -24,6 +24,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 +41,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> { @@ -215,6 +226,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(); }); @@ -329,4 +341,48 @@ 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(); + }); + }); }); diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index dbc07d791..a1adb2328 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -80,18 +80,27 @@ export interface SecretStore { * 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 - * 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. + * typical case is Linux without libsecret / gnome-keyring, or a + * container with no D-Bus session), `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. + * + * The `AsyncEntry` construction is deliberately **inside** each + * method's `try`: `AsyncEntry::new` performs the platform-store setup + * (on Linux, the Secret Service connect with a keyutils fallback) and + * throws when no backend is reachable. Constructing it outside the + * `try` let that raw error escape, 500ing every `GET /api/servers` + * before any secret was involved — the contract above only holds if + * construction failures are funneled through the same handlers (#1848). */ 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 entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); const v = await entry.getPassword(); return v ?? null; } catch { @@ -104,8 +113,8 @@ 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 entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); await entry.setPassword(value); } catch (err) { // The only operation that hard-fails — if we can't persist the @@ -116,17 +125,17 @@ export class KeyringSecretStore implements SecretStore { } async delete(serverId: string, field: string): Promise<void> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const entry = new 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. } } From a09f2dcf1da18c77bb04f6fb25b5e3ed7467e136 Mon Sep 17 00:00:00 2001 From: Cliff Hall <olahungerford@gmail.com> Date: Sat, 8 Aug 2026 21:15:20 -0400 Subject: [PATCH 18/93] fix: load @napi-rs/keyring lazily so an unsupported platform degrades instead of crashing (#1905) (#1948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: load @napi-rs/keyring lazily so an unsupported platform degrades `@napi-rs/keyring` ships one prebuilt binary per platform triple and throws on import where it has none. On Android / Termux there is no `@napi-rs/keyring-android-arm64`, so the static top-level import in `core/auth/node/secret-store.ts` threw during module evaluation and the Inspector exited at startup with "Cannot find native binding" — before any of the keychain-unavailable handling in that same file could run. Replaces the static import with a cached dynamic one. The outcome is cached, not just the module, so a box without a binary doesn't re-attempt (and re-throw) resolution on every secret operation — `expectedSecretFields` means that would otherwise be once per server per `GET /api/servers` — and so `set` can name the underlying cause in its `KeychainUnavailableError`. An unloadable package now folds into the same degradation contract as an unreachable keychain: `get` returns null, `delete` and `deleteAllForServer` no-op, and `set` throws `KeychainUnavailableError` (not double-wrapped — the load failure is already typed by the time the catch sees it). The class doc comment now enumerates all three ways "unavailable" can arrive, since each escaped the contract at some point. The shared `vi.mock` stub can't express this — it models a keyring that loads — so the new tests build on `vi.resetModules()` + `vi.doMock` with a throwing factory and re-import the module, covering the load failure, each method under it, and the load-once caching. Verified the dynamic import survives bundling in all three clients (cli, tui, and the web runner) rather than being inlined back to a static one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F * fix: steer a missing native binding to a reinstall hint `KeychainUnavailableError` gave the Linux libsecret / gnome-keyring advice for every cause, including the one where the keychain is fine and the @napi-rs/keyring platform package is what's missing — an unsupported triple (#1905) or npm's optional-deps bug dropping it on a supported one (npm/cli#4828, the Windows report on #1852). Detect the loader's "Cannot find native binding" phrasing and point those users at a reinstall / npx-cache clear instead. Ported from the superseded #1943, which fixed this alongside the same lazy-load change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: name a bad keyring namespace instead of "is not a constructor" The lazy import accepted whatever the resolution produced. The named exports arrive through CJS interop, so a resolution that stopped yielding them — a default-only export upstream, a bundler changing interop, a platform where named-export detection fails — would hand us `undefined`, and `new mod.AsyncEntry(...)` would throw a TypeError inside the very try that implements graceful degradation. Shape-check the resolved module and treat a bad shape as unavailable. Scope, because it is narrower than it first appears: this does NOT prevent 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 a dead keychain does. What changes is the diagnosis. Without the check the only signal is `set` reporting "keyring.mod.AsyncEntry is not a constructor", which reads like an Inspector bug; with it, `set` names the real problem once, at the load boundary, in the same actionable 503 as every other unavailability. Catching the silent-empty-list case itself would need a round-trip against the unmocked package, which nothing in the suite does today. The member access sits inside the try because reading a missing export is not always a harmless undefined — a Proxy-backed namespace can throw, as vitest's module mocks do — and letting that escape would reject the cached promise, which is otherwise guaranteed to always resolve. Of the four new tests only the cause-message one fails without the guard; the rest pin the surrounding contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * docs: correct the test header's count of unavailability entry points The shape check added a fourth way "unavailable" can arrive, and the enumeration in secret-store.ts was updated to match, but the test file's header still said three — so it read as an exhaustive list that silently omitted the case a whole describe below it covers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: give the wrong-shape cause its own remediation hint The shape check added a cause that fell through to the libsecret advice — so a packaging mismatch told the user to install a keyring daemon, which cannot help, and on Windows or macOS points somewhere that does not exist. That is the same wrong-advice bug the native-binding branch was added to fix, reintroduced by the guard itself. Hint selection moves into `hintFor`, where libsecret is the explicit fallback rather than the default, and the shape failure becomes a distinct `KeyringModuleShapeError` matched by type — it is our own error, so there is no reason to re-parse text we wrote (the native-binding branch matches a string only because that message comes from napi-rs). Also retitles the wrong-shape test: it claimed the guard avoids "returning null secrets", but `get` returns null either way by the read-tolerance contract. The title now says what actually differs — `set` hard-fails — matching the correction already made in the comments. Both raised by Copilot review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: give both shape-check failure paths the packaging hint The shape check can fail two ways — the members are absent, or reading them throws — and only the first carried KeyringModuleShapeError. The catch path returned the raw error, so a Proxy-backed namespace throwing on access fell through to the libsecret advice: the same wrong-remedy bug as the previous two commits, one branch further along. Both paths now carry the type. KeyringModuleShapeError takes a detail string plus a standard `cause`, so the throwing path reads "its exports could not be read: <original>" and keeps the underlying failure legible while still earning the packaging hint. Three instances of one mistake in a row (a new cause added without deciding which remediation it inherits) says the shape was wrong, not just the branch: making libsecret an explicit fallback in `hintFor` did not stop me adding a second exit that skipped it. The tests now pin the invariant directly — one asserts both construction paths produce the same hint — so the next cause that forgets is a failing test rather than a reviewer's catch. Raised by Copilot review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- clients/web/server/vite-base-config.ts | 4 +- .../auth/node/secret-store.test.ts | 315 +++++++++++++++++- core/auth/node/secret-store.ts | 236 +++++++++++-- 3 files changed, 525 insertions(+), 30 deletions(-) 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/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index 29130bfa2..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 @@ -81,6 +89,7 @@ import { InMemorySecretStore, KeyringSecretStore, KeychainUnavailableError, + KeyringModuleShapeError, SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, parseAccount, @@ -330,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 { @@ -386,3 +448,254 @@ describe("KeyringSecretStore (mocked native bindings)", () => { }); }); }); + +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/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index a1adb2328..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,28 +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, or a - * container with no D-Bus session), `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. + * **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 / 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: * - * The `AsyncEntry` construction is deliberately **inside** each - * method's `try`: `AsyncEntry::new` performs the platform-store setup - * (on Linux, the Secret Service connect with a keyutils fallback) and - * throws when no backend is reachable. Constructing it outside the - * `try` let that raw error escape, 500ing every `GET /api/servers` - * before any secret was involved — the contract above only holds if - * construction failures are funneled through the same handlers (#1848). + * 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> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + 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 { @@ -114,9 +276,20 @@ export class KeyringSecretStore implements SecretStore { async set(serverId: string, field: string, value: string): Promise<void> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + 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. @@ -126,7 +299,12 @@ export class KeyringSecretStore implements SecretStore { async delete(serverId: string, field: string): Promise<void> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + const keyring = await loadKeyring(); + if (!keyring.ok) return; + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); await entry.deleteCredential(); } catch { // Every reason for a throw collapses to the same desired outcome @@ -142,7 +320,9 @@ export class KeyringSecretStore implements SecretStore { 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; From 1881e5c73f551080f3509fbc22e933efe4bf31ce Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 17:02:05 -0400 Subject: [PATCH 19/93] fix: settle the deliberately in-flight tool calls in the progress tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two progress tests fire `client.callTool()` without holding the promise so they can assert on the notifications the call streams while it is still in flight. `disconnect()` then closes the SDK client, which rejects every pending request with "Connection closed" — with nothing holding the promise that lands as an unhandled rejection, which vitest counts as a run error and fails `npm run ci` at `coverage` even though all tests pass. Add a `settleInFlight()` helper that attaches the handler at call time (not after the assertions, which would leave a window the rejection can escape through) and returns a promise the test awaits after `disconnect()` so teardown stays ordered. Neither outcome is asserted: the call may legitimately complete before the teardown or reject with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219a..3586e51f2 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -95,6 +95,29 @@ 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, and `await` the returned promise + * after `disconnect()` so teardown stays ordered. The call may legitimately + * either settle before the teardown or reject with it, so neither outcome is + * asserted here. + */ +function settleInFlight(call: Promise<unknown>): Promise<void> { + return call.then( + () => undefined, + () => undefined, + ); +} + /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, @@ -2221,16 +2244,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, { @@ -2262,6 +2287,7 @@ describe("InspectorClient", () => { }); await client!.disconnect(); + await inFlight; await server.stop(); }); @@ -2349,15 +2375,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, { @@ -2380,6 +2408,7 @@ describe("InspectorClient", () => { expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); await client!.disconnect(); + await inFlight; await server.stop(); }); From 0b9aee11a313e4d9afe056575766730913c9f5a9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:01:27 -0400 Subject: [PATCH 20/93] fix: only absorb the teardown's own rejection in settleInFlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the blanket rejection handler also converted a genuine `callTool` failure into a pass. That matters here because these tests assert on the progress notifications, which arrive before the result — so a call that emitted all three progress events and then failed would have satisfied every assertion and been swallowed. Narrow the handler to absorb only `SdkError` / `CONNECTION_CLOSED` and re-throw anything else. The handler is still attached at call time, so an unexpected rejection stays *handled* and surfaces as an ordinary test failure at the `await inFlight` after `disconnect()` rather than as another unhandled rejection. Fulfillment is still accepted: whether the call beats the teardown is a race, so asserting either outcome would reintroduce the original bug as a flake. Verified the narrowed predicate is actually exercised rather than dead code — both call sites reject with SdkError/CONNECTION_CLOSED on every run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 3586e51f2..9f5379a7f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -107,14 +107,27 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * * Attach the handler at call time (not after the assertions) so there is no * window in which the rejection can escape, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. The call may legitimately - * either settle before the teardown or reject with it, so neither outcome is - * asserted here. + * after `disconnect()` so teardown stays ordered. + * + * Only the teardown's own `CONNECTION_CLOSED` rejection 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. Any + * *other* rejection is re-thrown, so a call that fails for a real reason still + * fails the test instead of passing on the strength of the progress + * notifications it managed to emit first. */ function settleInFlight(call: Promise<unknown>): Promise<void> { return call.then( () => undefined, - () => undefined, + (error: unknown) => { + if ( + error instanceof SdkError && + error.code === SdkErrorCode.ConnectionClosed + ) { + return; + } + throw error; + }, ); } From c60563b7d647303ce15b34690836f3ffd8a7f127 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:09:52 -0400 Subject: [PATCH 21/93] fix: observe the derived promise so an unexpected rejection can't go unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 2). Narrowing the handler in the previous commit fixed the silent-pass but reintroduced #1947's own failure class on the unexpected path: `then` returns a *derived* promise, and the re-throw rejects that one rather than `call`. The caller doesn't await it until after `disconnect()`, so a rejection arriving while the test is still waiting on progress notifications sits unobserved for seconds and Node reports it as an unhandled rejection. Attach an observer to the derived promise the moment it exists. Nothing is swallowed — it stays rejected, so the caller's `await` still fails the test. Verified both directions with a probe rejecting with a non-ConnectionClosed error and awaiting after a 1.5s gap: without the observer the run reports "Unhandled Rejection" + "Errors 1 error"; with it, the test fails and the run stays clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../src/test/integration/mcp/inspectorClient.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9f5379a7f..9a3a690cd 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -117,7 +117,7 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * notifications it managed to emit first. */ function settleInFlight(call: Promise<unknown>): Promise<void> { - return call.then( + const settled = call.then( () => undefined, (error: unknown) => { if ( @@ -129,6 +129,15 @@ function settleInFlight(call: Promise<unknown>): Promise<void> { 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` still fails the test. + settled.catch(() => undefined); + return settled; } /** Get all resources from the client via listResources() (paginates if needed). */ From 347173fab7363fa25f0a50b1b4b246c72028757f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:19:33 -0400 Subject: [PATCH 22/93] fix: absorb only the teardown's own close, not any ConnectionClosed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 3). The predicate absorbed *any* CONNECTION_CLOSED, while the doc comment claimed it absorbed only the teardown's — so a transport dropping on its own after the progress notifications but before `disconnect()` would have been swallowed. These tests assert only on the notifications, so that regression would have passed green. Gate the absorption on a teardown flag owned by the helper, which now returns an `InFlightCall` exposing `disconnectAndSettle()`. Keeping the flag inside means it can't be raised too early (which would silently reopen the hole) and the settle can't be forgotten; call sites also collapse from two statements to one. Verified: a pre-teardown ConnectionClosed now fails the test and stays observed (no run-level error), while a teardown-time one is still absorbed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9a3a690cd..cc208835d 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -106,21 +106,33 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * 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, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. + * window in which the rejection can escape, then finish through + * `disconnectAndSettle()`, which tears down and awaits the call in one step. * - * Only the teardown's own `CONNECTION_CLOSED` rejection is absorbed. Plain + * 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. Any - * *other* rejection is re-thrown, so a call that fails for a real reason still - * fails the test instead of passing on the strength of the progress - * notifications it managed to emit first. + * 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. */ -function settleInFlight(call: Promise<unknown>): Promise<void> { +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 ) { @@ -135,9 +147,15 @@ function settleInFlight(call: Promise<unknown>): Promise<void> { // 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` still fails the test. + // stays rejected, so the caller's `await` below still fails the test. settled.catch(() => undefined); - return settled; + 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). */ @@ -2308,8 +2326,7 @@ describe("InspectorClient", () => { progressToken: progressToken.toString(), }); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); @@ -2429,8 +2446,7 @@ describe("InspectorClient", () => { }); expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); From 2e8944373937a32e450eca2eab588e6e6500abfb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 19:23:43 -0400 Subject: [PATCH 23/93] =?UTF-8?q?docs:=20add=20the=20v1=20=E2=86=92=20v2?= =?UTF-8?q?=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1822 Adds docs/v1-to-v2-migration.md — the map for users arriving from v1: - CLI flag mapping (every v1 flag survives; lists what v2 adds) - `--config` vs `--catalog` semantics with before/after examples - Node engine bump (>=22.7.5 → >=22.19.0) - what no longer ships (the three sub-packages; v2 is one tarball) - the three behavior changes most likely to bite: exit code 5 on a failing tool call, target-must-come-first under --cli, and the reversed `--` separator - env-var mapping (MCP_PROXY_AUTH_TOKEN → MCP_INSPECTOR_API_TOKEN, MCP_PROXY_FULL_ADDRESS removed, SERVER_PORT repurposed), the v1 UI config settings, web query params, and Docker Linked from the root README (banner + docs list), the CLI README, and docs/mcp-server-configuration.md; AGENTS.md's docs/ tree entry updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- AGENTS.md | 218 +++++++++++---------- README.md | 51 ++--- clients/cli/README.md | 2 + docs/mcp-server-configuration.md | 1 + docs/v1-to-v2-migration.md | 325 +++++++++++++++++++++++++++++++ 5 files changed, 469 insertions(+), 128 deletions(-) create mode 100644 docs/v1-to-v2-migration.md diff --git a/AGENTS.md b/AGENTS.md index a4185baaf..c1039e878 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, @@ -120,20 +121,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 #<ISSUE_NUMBER>` (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,14 +151,14 @@ 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 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. - 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). + 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. - **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. @@ -168,7 +171,8 @@ 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: @@ -185,36 +189,36 @@ Every issue gets a **Priority on its board card**, set when you add the issue to > ⚠️ **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: @@ -227,14 +231,14 @@ 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. @@ -250,7 +254,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,13 +263,14 @@ 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) @@ -274,7 +279,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor #### 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 +287,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. 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 +335,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? @@ -390,20 +396,20 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i 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. -| 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. @@ -416,6 +422,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-i 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. ### 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,37 +440,40 @@ 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"` 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. ### 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). - **`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. - **`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` / `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). -- `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: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 @@ -482,7 +492,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/`. @@ -492,6 +502,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 @@ -507,7 +518,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()`. @@ -515,32 +526,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. @@ -563,4 +574,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/README.md b/README.md index 137927642..b5d5d7a4d 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 @@ -38,7 +40,7 @@ inspector/ │ └── 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) +├── 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. @@ -130,18 +133,18 @@ This is what lets an Inspector connection negotiating `protocolEra: "auto" | "mo Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -| Config | Demonstrates | Issue | -| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| 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) | -| `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) | +| `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) | +| `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 @@ -261,17 +264,17 @@ 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 two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | -| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | -| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | -| `npm run 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 `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 two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | +| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards _this_ gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run 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). | 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. @@ -287,7 +290,7 @@ 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. +- **`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 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 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/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 98336df1d..88250cd62 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -201,5 +201,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..ef2865fc9 --- /dev/null +++ b/docs/v1-to-v2-migration.md @@ -0,0 +1,325 @@ +# 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. `npx` will refuse to run on an older Node rather than fail obscurely later. + +## 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>`. There is no second port to expose, forward, or allow 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 only needs `-p 6274:6274`. The v1 recipe published `6277` as well. + +## 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 such default — it started empty every time. + +### 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 thing, same read-only guarantee +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything +``` + +⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI 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. + +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 rule 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; three 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 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). 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. + +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. + +## Environment variables + +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working | +| `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 — one port; the image already sets the wildcard-bind opt-in +docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +``` + +Notes: + +- 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. + +**"`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. From 6f535356b452871a8e6cf76837ad64a4b2481e39 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 19:56:38 -0400 Subject: [PATCH 24/93] fix: key tool rows by source position so duplicate names filter correctly `ToolControls` keyed both the main list and the SEP-2243 excluded list by `tool.name`. A server may legitimately return the same tool name more than once, and colliding React keys let a filtered-out row survive reconciliation: searching narrows the array, React matches the surviving rows against the stale keys, and an unrelated row stays mounted (plus a "two children with the same key" console warning). Stamp each row's position in the unfiltered list before filtering and use `<index>:<name>` as the key, on both lists. The position is captured before the filter, so the key stays stable as the search narrows. No deduplication, no change to what `tools/list` returns, and the name/title search behavior is unchanged. Adds regression tests for both lists using the reporter's fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../groups/ToolControls/ToolControls.test.tsx | 85 +++++++++++++++++++ .../groups/ToolControls/ToolControls.tsx | 45 ++++++---- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index 1ce40d1fa..ef3d0210c 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -204,6 +204,91 @@ describe("ToolControls", () => { expect(onRefreshList).toHaveBeenCalledTimes(1); }); + // A server may legitimately return the same tool name twice; keying rows by + // name alone collides, and React then reuses the stale row instead of + // unmounting it when the filter narrows (#1957). + const duplicateNameTools: Tool[] = [ + { + name: "get_record", + title: "Get Record First", + inputSchema: { type: "object" }, + }, + { + name: "duplicate_tool", + title: "First Duplicate", + inputSchema: { type: "object" }, + }, + { + name: "unrelated_tool", + title: "Unrelated Tool First", + inputSchema: { type: "object" }, + }, + { + name: "duplicate_tool", + title: "Second Duplicate", + inputSchema: { type: "object" }, + }, + { + name: "get_record", + title: "Get Record Second", + inputSchema: { type: "object" }, + }, + { + name: "unrelated_tool", + title: "Unrelated Tool Second", + inputSchema: { type: "object" }, + }, + ]; + + it("removes every non-matching row when tool names repeat (#1957)", async () => { + const user = userEvent.setup(); + renderWithMantine(<ControlledToolControls tools={duplicateNameTools} />); + + await user.type(screen.getByPlaceholderText("Search tools..."), "get"); + + expect(screen.getByText("Get Record First")).toBeInTheDocument(); + expect(screen.getByText("Get Record Second")).toBeInTheDocument(); + for (const stale of [ + "First Duplicate", + "Second Duplicate", + "Unrelated Tool First", + "Unrelated Tool Second", + ]) { + expect(screen.queryByText(stale)).not.toBeInTheDocument(); + } + }); + + 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 1806b0c31..4ad70d854 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -108,6 +108,18 @@ const ExcludedTooltip = Tooltip.withProps({ position: "right", }); +// A server may return the same tool name more than once, so the name alone is +// not a unique React key — colliding keys let a filtered-out row survive +// reconciliation instead of unmounting (#1957). The tool's position in the +// unfiltered list disambiguates duplicates and stays stable while the search +// narrows, since it is captured before filtering. +const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`; + +/** Matches a tool against the (already lower-cased) search query by name or title. */ +const matchesQuery = (tool: Tool, query: string) => + tool.name.toLowerCase().includes(query) || + (tool.title?.toLowerCase().includes(query) ?? false); + export function ToolControls({ tools, excludedTools = [], @@ -122,22 +134,19 @@ export function ToolControls({ }: ToolControlsProps) { const viewportRef = useScrollMemory("tools-sidebar"); const query = searchText.toLowerCase(); - const filteredTools = searchText - ? tools.filter( - (tool) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : tools; + // Stamp each row's source position before filtering, so the key survives the + // list narrowing (#1957). + const filteredTools = tools + .map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) })) + .filter(({ tool }) => !searchText || matchesQuery(tool, query)); // Excluded tools are searchable too, matching name AND title like the main // list above, so a filtered view stays consistent. - const filteredExcluded = searchText - ? excludedTools.filter( - ({ tool }) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : excludedTools; + const filteredExcluded = excludedTools + .map((excluded, sourceIndex) => ({ + ...excluded, + key: rowKey(excluded.tool.name, sourceIndex), + })) + .filter(({ tool }) => !searchText || matchesQuery(tool, query)); return ( <SidebarStack> @@ -156,9 +165,9 @@ export function ToolControls({ <ListLoadError error={loadError} what="tools" onRetry={onRefreshList} /> <SidebarScroll viewportRef={viewportRef}> <Stack gap="xs"> - {filteredTools.map((tool) => ( + {filteredTools.map(({ tool, key }) => ( <ToolListItem - key={tool.name} + key={key} tool={tool} selected={tool.name === selectedName} onClick={() => { @@ -169,8 +178,8 @@ export function ToolControls({ {filteredExcluded.length > 0 && ( <> <ExcludedDivider /> - {filteredExcluded.map(({ tool, reason }) => ( - <ExcludedTooltip key={tool.name} label={reason}> + {filteredExcluded.map(({ tool, reason, key }) => ( + <ExcludedTooltip key={key} label={reason}> <ExcludedRow> <ExcludedWarningIcon> <RiErrorWarningLine /> From 4340283f7030d6a6df77b84c98d42a5a1571eeb0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:22:47 -0400 Subject: [PATCH 25/93] test: add a duplicate-tool-names test server for the #1957 repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the repo could serve the shape this fix is about: every preset registers a unique name, and the SDK's `registerTool` rejects a repeat. So the only reproduction was a unit test — there was no way to smoke the real app against a server that returns duplicates. Adds `ServerConfig.duplicateToolNames` (a list of registered tool names) to the composable test server, plumbed through the JSON config, and a showcase config `duplicate-tool-names-http.json`. The named tools are emitted a second time in `tools/list` with the same `name` and a "(duplicate)" title. The repeats are appended after the whole list rather than placed beside their twin. That is both the realistic shape (two tool sources concatenated) and what makes the defect observable: React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the bug hides. Only a separated pair collides in the keyed map and orphans a row — verified both ways against the prod bundle. The `tools/list` override is shared with the existing pagination handler (which already hand-builds the list); with no `maxPageSize` it returns a single unbounded page. Duplication happens before pagination, so a duplicated pair can straddle a page boundary the way a real server's would. Also adds a ToolControls test for this appended ordering, distinct from the reporter's interleaved fixture, and documents the config in the README showcase table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- README.md | 9 +++ .../groups/ToolControls/ToolControls.test.tsx | 38 ++++++++++++ .../configs/duplicate-tool-names-http.json | 17 ++++++ test-servers/src/composable-test-server.ts | 58 +++++++++++++++++-- test-servers/src/load-config.ts | 6 ++ test-servers/src/resolve-config.ts | 1 + 6 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test-servers/configs/duplicate-tool-names-http.json diff --git a/README.md b/README.md index 137927642..f650dcdfe 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `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) | +| `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) | @@ -206,6 +207,14 @@ 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. +#### 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`. diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index ef3d0210c..b6e379e86 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -258,6 +258,44 @@ describe("ToolControls", () => { } }); + // 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 = [ 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/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/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") From 6c33da38c964ae38b4b4e28f8c33fc379951166d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:29:58 -0400 Subject: [PATCH 26/93] chore(deps): align zod across installs and guard against version skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1896 `clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The cause is not a zod regression nor a pathological inference site of ours: it is two copies of zod at *mismatched versions* inside one tsc program. `clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files resolve zod (and the SDK's zod) from the **root** install, alongside web's own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs 4.4.3 TypeScript must relate two structurally-distinct declarations of every `@modelcontextprotocol/*` schema type, which is exponential over a recursive generic surface — `TS2589 Type instantiation is excessively deep`, then OOM. Bumping root zod to 4.4.3 and changing nothing else returns the build to baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at 4.3.6. Two copies at the same version are harmless; skew is what explodes. So drop the `~4.3.6` hold on `clients/web` and move all four manifests and lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate` to keep them there. The guard derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and fails deny-by-default on any skew not in the annotated `TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so dropping it from `validate` is caught by `verify:format-coverage`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 1 + AGENTS.md | 3 +- README.md | 7 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 2 +- clients/tui/package.json | 2 +- clients/web/package-lock.json | 8 +- clients/web/package.json | 2 +- package-lock.json | 8 +- package.json | 5 +- scripts/verify-dep-lockstep.mjs | 280 +++++++++++++++++++++++++++ scripts/verify-dep-lockstep.test.mjs | 196 +++++++++++++++++++ scripts/verify-format-coverage.mjs | 11 +- 14 files changed, 505 insertions(+), 24 deletions(-) create mode 100644 scripts/verify-dep-lockstep.mjs create mode 100644 scripts/verify-dep-lockstep.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd15..a49cec610 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- **A dependency bump must land in every install.** 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 touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index a4185baaf..1d38a6e2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,11 +448,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### 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 install-crossing dependency resolves to two different versions), 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). - 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 `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), 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. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. 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` / `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. diff --git a/README.md b/README.md index 137927642..635d93cfb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ inspector/ │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests -├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) +├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify) ├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) @@ -263,13 +263,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, 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 two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles `core/` and `test-servers/src` — which resolve 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 `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. 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). | diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e44..926899e73 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -19,7 +19,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" diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f37..7dfbad9c7 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -43,7 +43,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/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3ea..431b60d8c 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -20,7 +20,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" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc5..b2ffcc927 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -39,7 +39,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/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7e..11cfb0980 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -35,7 +35,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" @@ -11677,9 +11677,9 @@ } }, "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/clients/web/package.json b/clients/web/package.json index a347376e5..d15870e63 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -61,7 +61,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", diff --git a/package-lock.json b/package-lock.json index 58f21e7a3..4766aff22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "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" @@ -4534,9 +4534,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 d3467cf6d..0d3534534 100644 --- a/package.json +++ b/package.json @@ -43,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", @@ -98,7 +99,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs new file mode 100644 index 000000000..496fdc8d8 --- /dev/null +++ b/scripts/verify-dep-lockstep.mjs @@ -0,0 +1,280 @@ +#!/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 +// `core/` and `test-servers/src`, the two first-party 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. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { builtinModules } 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"]; + +// 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. +// +// 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; +} + +// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic +// `import("x")`. Only these three forms introduce a dependency's *types*. +const SPECIFIER_FORMS = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/** + * Every third-party package name imported by a blob of TypeScript source. + * Deliberately a regex scan rather than a parse: it only needs to over- rather + * than under-approximate, since a name absent from every lockfile contributes + * nothing downstream (`@inspector/core` is a build-time alias, not a package, + * and drops out that way). + */ +export function importedPackageNames(source) { + const names = new Set(); + for (const re of SPECIFIER_FORMS) { + for (const m of source.matchAll(re)) { + const name = packageNameOf(m[1]); + if (name) 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; +} + +/** + * 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; +} + +/** Split skewed packages into the tolerated ones and the failures. */ +export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + return { + failures: skewed.filter((s) => !tolerated.has(s.name)), + ignored: skewed.filter((s) => tolerated.has(s.name)), + }; +} + +/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +function sharedSourceFiles() { + const out = execFileSync( + "git", + ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + { cwd: repoRoot, encoding: "utf8" }, + ); + return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); +} + +/** + * 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() + : []; + return ["."] + .concat(clients) + .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.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(); + if (files.length === 0) { + console.error( + `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + ); + 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(); + const installs = dirs.map((dir) => ({ + dir, + versions: topLevelLockVersions( + JSON.parse( + readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), + ), + ), + })); + + 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`, + ); + for (const { name, holders } of failures) { + console.error(` ${name}`); + for (const { dir, version } of holders) + console.error(` ${version} (${dir})`); + } + console.error( + "\nThese packages' types are compiled into a single `tsc` program from two installs" + + `\n(${SHARED_SOURCE_DIRS.join(" and ")} 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 so all lockfiles agree — or, if this" + + "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + + "\nwith the reason. See AGENTS.md.", + ); + 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..6e36d2552 --- /dev/null +++ b/scripts/verify-dep-lockstep.test.mjs @@ -0,0 +1,196 @@ +// 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, + importedPackageNames, + packageNameOf, + partitionSkew, + topLevelLockVersions, +} from "./verify-dep-lockstep.mjs"; + +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: the three specifier forms that introduce types", () => { + 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: prose after `from` is not an import", () => { + // The scan over-approximates on purpose, but must not invent package names + // out of comment text — a bogus name absent from every lockfile is inert, + // yet a *plausible* one would silently widen the candidate set. + const source = ` + // Resolved relative to the runner dir, not from "cwd omitted" by the caller. + /** Reads the manifest from "file:" URLs. */ + 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", () => { + for (const lock of [undefined, null, {}, { packages: {} }]) + assert.equal(topLevelLockVersions(lock).size, 0); +}); + +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: [] }, + { name: "zod", holders: [] }, + ]; + 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: [] }]; + assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 8963c8c24..014b850fd 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,15 +166,16 @@ 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, but the +// three can each assert the others are still wired into `validate`, so dropping +// any one is caught here. Only deleting all of them 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); } From e328da43b5cc9fb4d9e9c4a1700a5ecfdf9e41d8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:34:14 -0400 Subject: [PATCH 27/93] test: cover the duplicateToolNames server option over a live connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ToolControls regressions build their `Tool[]` by hand, so they would still pass if the new server option broke — config loading, resolveConfig, the ordering, the title marker, or its composition with pagination. Copilot flagged exactly that gap. Adds an integration test that connects a real InspectorClient to a real streamable-HTTP test server and asserts the wire shape: repeats appended (not adjacent — the ordering the defect depends on), only the appended copy marked "(duplicate)", an unregistered name ignored, an empty list a no-op, and duplication running before the page slice so a pair straddles a boundary. The last case loads `duplicate-tool-names-http.json` through loadConfig + resolveConfig, so the JSON plumbing the manual repro uses is covered too. Verified non-vacuous: mutating withDuplicates to place repeats adjacent fails four of the five. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../mcp/duplicate-tool-names.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts 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); + }); +}); From e480fdc49d5923e17a9b85407a660d6599990c8b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:38:41 -0400 Subject: [PATCH 28/93] chore(deps): cover .mts/.cts in the shared-source scan, fix the README validate row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Copilot's review of #1962. The candidate-set enumeration matched only `.ts`/`.tsx`, while `verify:format-coverage` and `verify:typecheck-coverage` both gate all four TypeScript extensions. No `.mts`/`.cts` exists under `core/` or `test-servers/src` today, which is exactly why the omission would have gone unnoticed until a shared dependency arrived through one and skewed — the failure this guard exists to prevent. Extract the rule as the exported `isSharedSourceFile` predicate, cover all four extensions, and pin it with regression tests (including path-boundary anchoring, so a `core-internal/` sibling isn't swept in). The README's `validate` row also omitted `test:scripts` and claimed typecheck was cli/tui only; launcher runs one too, and web typechecks via `tsc -b` inside its `build`. Restate the row to match the actual script chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 20 +++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 32 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 635d93cfb..fef78256e 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no install-crossing dependency skews) — 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`. | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 496fdc8d8..487a927cb 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -170,14 +170,30 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { }; } -/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +// 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 a TypeScript source of a shared tree. */ +export function isSharedSourceFile(file) { + 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}/`)); +} + +/** Tracked TypeScript files under the shared first-party source trees. */ function sharedSourceFiles() { const out = execFileSync( "git", ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], { cwd: repoRoot, encoding: "utf8" }, ); - return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); + return out.split("\n").filter(isSharedSourceFile); } /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6e36d2552..640291fb5 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -9,11 +9,43 @@ import assert from "node:assert/strict"; import { findSkew, importedPackageNames, + isSharedSourceFile, packageNameOf, partitionSkew, topLevelLockVersions, } 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("packageNameOf: bare names, scopes, and subpaths", () => { const cases = [ ["zod", "zod"], From 20527dc1776a1f75d09d7670ff84f02c1e63c974 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:40:39 -0400 Subject: [PATCH 29/93] =?UTF-8?q?docs:=20address=20Copilot=20review=20on?= =?UTF-8?q?=20the=20v1=20=E2=86=92=20v2=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sandbox port: the Apps tab needs a second, dynamic listener, so "no second port" was wrong for Docker/remote deployments. Qualify the architecture section, add a pinned-port Docker recipe, and add a troubleshooting entry. - Node floor: npm only warns EBADENGINE unless engine-strict is set, so npx does not refuse to run on an older Node. - v1 default list: the web UI persisted its list in localStorage; only the on-disk default catalog is new. - The v1→v2 --server example needs --cli (and a --method) to select an entry; note there is no one-entry web equivalent. - New behavior change #4: an ambiguous URL path (neither /mcp nor /sse) now errors instead of falling back to SSE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 51 ++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index ef2865fc9..745779c90 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -30,7 +30,7 @@ The three changes most likely to bite an existing setup: ## Requirements -v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. `npx` will refuse to run on an older Node rather than fail obscurely later. +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 @@ -58,13 +58,15 @@ If you depend on one of them directly, drop it and use the root package's `mcp-i 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>`. There is no second port to expose, forward, or allow through a firewall. +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 only needs `-p 6274:6274`. The v1 recipe published `6277` as well. +- 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 @@ -102,7 +104,7 @@ 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 such default — it started empty every time. +- **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 @@ -112,11 +114,11 @@ So: # v1 — one entry out of a file npx @modelcontextprotocol/inspector --config ./mcp.json --server everything -# v2 — same thing, same read-only guarantee -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 ``` -⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI you pick the server from the list after it loads. +⚠️ **`--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) @@ -153,7 +155,7 @@ Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renam | `-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 rule below | +| `[target...]` | same | but see the target-ordering **and** URL-transport rules below | New in v2, with no v1 equivalent: @@ -175,7 +177,7 @@ See the [CLI README](../clients/cli/README.md) for the full surface. ## CLI behavior changes -The flags survived; three behaviors did not. +The flags survived; four behaviors did not. ### 1. Exit codes and the error envelope @@ -234,6 +236,24 @@ mcp-inspector --cli node build/index.js -- --method tools/list So the web example above, run 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). 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. +### 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)`, exactly as v1 did. ## Environment variables @@ -293,12 +313,19 @@ 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 — one port; the image already sets the wildcard-bind opt-in +# v2 — no proxy port; the image already sets the wildcard-bind opt-in docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: +- **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 6274:6274 -p 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`. @@ -314,6 +341,10 @@ Notes: **"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`. From 7a74b5eba192f8fe2dbfdbc491bc61580041ceb8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:44:37 -0400 Subject: [PATCH 30/93] fix: forward the negotiated protocol version to the remote backend transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web client's SDK Client owns the `initialize` handshake, so the `setProtocolVersion()` call it makes after initialize lands on RemoteClientTransport — a pass-through to the Node backend, not the real HTTP transport. The backend's StreamableHTTPClientTransport therefore never learned the negotiated version and stamped no `Mcp-Protocol-Version` on any request after initialize, starting with `notifications/initialized`. A stateful server that requires the header rejects that notification with HTTP 400. RemoteClientTransport now records the version and rides it on every `/api/mcp/send`; the backend applies it to its transport before the send, so that request and everything the transport issues afterwards (the standalone SSE GET, the session DELETE) carry the header. The version is validated against a token pattern on the backend so a client can't push arbitrary bytes into an upstream header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 57 +++++++++++++++++++ .../mcp/remote/remote-session.test.ts | 34 +++++++++++ .../integration/mcp/remote/transport.test.ts | 52 +++++++++++++++++ core/mcp/remote/node/remote-session.ts | 26 +++++++++ core/mcp/remote/node/server.ts | 8 ++- core/mcp/remote/remoteClientTransport.ts | 22 +++++++ core/mcp/remote/types.ts | 6 ++ 7 files changed, 204 insertions(+), 1 deletion(-) 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..3c51abcbf 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,61 @@ 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: fetchFn as unknown as typeof fetch }, + 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/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 5fe6f4dec..a74e980cc 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 @@ -120,6 +120,40 @@ 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({ setProtocolVersion } as unknown as Transport); + + 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 or non-token version", () => { + const session = new RemoteSession("s8b"); + const setProtocolVersion = vi.fn(); + session.setTransport({ setProtocolVersion } as unknown as Transport); + + session.applyProtocolVersion(undefined); + // Header injection attempt — must never reach the upstream transport. + session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1"); + session.applyProtocolVersion(""); + expect(setProtocolVersion).not.toHaveBeenCalled(); + }); + + it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => { + const session = new RemoteSession("s8c"); + session.setTransport({} as unknown as Transport); + 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/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/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 34a2db098..60ee176fa 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,29 @@ 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`). + */ + applyProtocolVersion(version: string | undefined): void { + if ( + version === undefined || + 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..8a3f2a05b 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -728,7 +728,8 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { sessionId, message, relatedRequestId, headers } = body; + const { sessionId, message, relatedRequestId, headers, protocolVersion } = + body; if (!sessionId || !message) { return c.json({ error: "Missing sessionId or message" }, 400); } @@ -744,6 +745,11 @@ export function createRemoteApp( return c.json({ ok: false, kind: "transport_error", error: errorMsg }); } + // The browser's SDK Client negotiated the version; hand it to the real + // upstream transport before the send so `Mcp-Protocol-Version` is stamped + // on this request and everything after it (#1935). + session.applyProtocolVersion(protocolVersion); + session.beginSend(); const requestId = requestIdForSendWait(message); let responseWait: Promise<void> | undefined; 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 = From 47e0ee059a0dc1a1134362c6a8369b104298772b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:47:09 -0400 Subject: [PATCH 31/93] chore(deps): match CommonJS and template-literal imports in the shared-source scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the suppressed comment on Copilot's second pass of #1962. Under- approximating is the dangerous direction here: a package the scan misses never enters the candidate set, so its skew passes the guard silently. Three forms went unmatched. `import x = require("pkg")` and a bare `require("pkg")` are the ordinary import syntax in `.cts` — which the previous commit just admitted to the scan, so leaving them out would have been an inconsistency introduced by that widening. A dynamic import carrying import attributes (`import("pkg", { with: … })`) was missed because the pattern required the closing paren. A static template literal (`import(\`pkg\`)`) was missed for want of a backtick in the quote class. Backticks are accepted only in the *call* forms. A `from` clause requires a string literal, so allowing them there would buy nothing while reopening the prose hazard the anchored specifier pattern closes: inline code in comments is written with backticks throughout this codebase, and admitting it inflated the derived set from 18 names to 41 with prose fragments (`tools`, `connect()`, `messages`). Inert today, since a name absent from every lockfile contributes nothing — but a prose word colliding with a real package name would silently widen the set. Pinned with a regression test. The real set is unchanged at 18, so these forms add no package today; they close the syntax against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 23 ++++++++++++++----- scripts/verify-dep-lockstep.test.mjs | 33 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 487a927cb..0eb8a9dda 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -101,12 +101,25 @@ export function packageNameOf(specifier) { return name; } -// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic -// `import("x")`. Only these three forms introduce a dependency's *types*. +// Every form that can introduce a dependency's *types* (Copilot, #1962). +// `import x = require("pkg")` and a bare `require("pkg")` matter because the +// shared trees may hold `.cts` sources, where that is the ordinary import +// syntax; and neither call form requires the closing paren, so an import +// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// +// Backticks are accepted ONLY in the call forms, where a static template +// literal is legal. A `from` clause requires a string literal, so allowing +// backticks there would buy nothing while reopening the prose hazard the +// anchored specifier pattern exists to close — inline code in a comment is +// written with backticks throughout this codebase, and "…derived from +// `tools`…" would otherwise enter the candidate set as a package named +// `tools`. Inert today, but a prose word that collides with a real package +// name would silently widen the set. const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, - /\bimport\s+["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" + /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" + /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) + /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 640291fb5..890d90b57 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -79,6 +79,26 @@ test("packageNameOf: non-packages are rejected", () => { 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: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; @@ -109,6 +129,19 @@ test("importedPackageNames: prose after `from` is not an import", () => { assert.deepEqual([...importedPackageNames(source)], ["zod"]); }); +test("importedPackageNames: a backticked `from` in prose is not an import", () => { + // Backticks are legal in the *call* forms (a static template literal) but + // never after `from`, which requires a string literal. Accepting them there + // would sweep in inline code from comments — this codebase writes it with + // backticks throughout — and a prose word colliding with a real package name + // would silently widen the candidate set. + const source = ` + /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + 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 From 908284d04655c865a8a90be8f1035460cb6e3495 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:47:40 -0400 Subject: [PATCH 32/93] docs: address Copilot's suppressed comments on the migration guide - `--` under --cli: everything before the separator IS forwarded to the target, flags included (cli.ts:536-541), so "no way to pass a leading-dash argument" was wrong. Show the correct placement instead of sending users to a catalog file. - Stdout: v2's emitResult appends "\n" where v1's awaitableLog wrote the JSON with no terminator, so the output is not byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 745779c90..04f2a640b 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -234,7 +234,17 @@ Under **`--cli`** it is reversed: everything _before_ `--` is the target, everyt mcp-inspector --cli node build/index.js -- --method tools/list ``` -So the web example above, run 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). 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 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 @@ -254,7 +264,7 @@ v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://examp 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)`, exactly as v1 did. +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 From 8c9cf40211700474a26a922862742cbfc1490b35 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:53:45 -0400 Subject: [PATCH 33/93] docs: qualify the ad-hoc rule and fix the contradicting `--` guidance - The "nothing combines with an ad-hoc target" rule has a web exception: run-web.ts:130 exempts `--transport stdio`, so web ignores it rather than rejecting. Only cli/tui reject every `--transport`. - mcp-server-configuration.md still said a leading-dash argument cannot reach a stdio server under --cli, the opposite of what the migration guide now says. The parser puts every pre-`--` token in targetArgs, so the guide is right; corrected the older section to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/mcp-server-configuration.md | 8 +++++++- docs/v1-to-v2-migration.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 88250cd62..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 diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 04f2a640b..e086a6742 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -128,7 +128,7 @@ npx @modelcontextprotocol/inspector --catalog ./mcp.json 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. +**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. From ddfce0f7ac30cab4316809ae3d6d90f97a238d3f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:55:51 -0400 Subject: [PATCH 34/93] fix: reject a non-string protocolVersion, and drop the test double casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review follow-ups: - `applyProtocolVersion` took the value straight from an unvalidated JSON body and went to `RegExp.test`, which coerces — `123` and `true` both matched the token pattern and reached `Transport.setProtocolVersion` with the wrong type. It now takes `unknown` and checks `typeof === "string"` first, with cases for the coercible values. - Replace `as unknown as Transport` in the new session fixtures with a `makeTransport()` helper returning a structurally valid `Transport`, so an interface change stays visible to the tests. - Drop `as unknown as typeof fetch` in the new transport test — `vi.fn<typeof fetch>()` is already the right type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 5 +--- .../mcp/remote/remote-session.test.ts | 23 +++++++++++++++---- core/mcp/remote/node/remote-session.ts | 9 +++++--- 3 files changed, 26 insertions(+), 11 deletions(-) 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 3c51abcbf..c04788510 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -542,10 +542,7 @@ describe("RemoteClientTransport", () => { return new Response("not found", { status: 404 }); }); - const transport = new RemoteClientTransport( - { baseUrl, fetchFn: fetchFn as unknown as typeof fetch }, - config, - ); + const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config); await transport.start(); // Pre-initialize sends carry no version (none negotiated yet). 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 a74e980cc..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"); @@ -125,7 +135,7 @@ describe("RemoteSession", () => { it("applyProtocolVersion forwards a new version to the transport once", () => { const session = new RemoteSession("s8a"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + session.setTransport(makeTransport({ setProtocolVersion })); session.applyProtocolVersion("2025-11-25"); session.applyProtocolVersion("2025-11-25"); @@ -136,21 +146,26 @@ describe("RemoteSession", () => { expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28"); }); - it("applyProtocolVersion ignores an absent or non-token version", () => { + it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => { const session = new RemoteSession("s8b"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + 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({} as unknown as Transport); + session.setTransport(makeTransport()); expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow(); }); diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 60ee176fa..fc7c7654a 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -69,11 +69,14 @@ export class RemoteSession { * * 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`). + * 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: string | undefined): void { + applyProtocolVersion(version: unknown): void { if ( - version === undefined || + typeof version !== "string" || version === this.appliedProtocolVersion || !RemoteSession.PROTOCOL_VERSION_PATTERN.test(version) ) { From 4fe02bca45d3e3e0fcb01169d3b4ddf96dde2084 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:59:38 -0400 Subject: [PATCH 35/93] docs: keep the Docker recipes loopback-only; note the legacy token fallback - The v1 recipe published on 127.0.0.1 and the v2 one silently dropped that. Docker's -p binds host interfaces independently of the container's HOST, and a no-Origin request skips the allow-list (core/mcp/remote/node/server.ts:241), so the token is the only guard on a process-spawning backend. Restore the loopback prefix on both recipes and say why. - MCP_PROXY_AUTH_TOKEN is not removed: web-server-config.ts:329 still falls back to it. Call it a deprecated fallback rather than a rename, and distinguish it from the query param, which has no fallback (App.tsx getAuthToken reads only the new name). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index e086a6742..a2db3f55a 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -268,21 +268,21 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th ## Environment variables -| v1 | v2 | Notes | -| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working | -| `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 | +| 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 @@ -324,15 +324,17 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ ghcr.io/modelcontextprotocol/inspector:1.0.1 # v2 — no proxy port; the image already sets the wildcard-bind opt-in -docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +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 6274:6274 -p 6280:6280 -e MCP_SANDBOX_PORT=6280 \ + 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 ``` From 35317fd9ed3062ec93c42a08ff429bc979028b0b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:00:31 -0400 Subject: [PATCH 36/93] chore(deps): scope the allowlist to a major, cover vitest.shared.mts, fix the vouch comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four suppressed comments from Copilot's third pass of #1962. Each was correct. The candidate boundary omitted `vitest.shared.mts` — root-owned, imported by every client's vitest config, and already treated as shared non-client source by `verify:typecheck-coverage`. It imports only Node built-ins today, which is exactly why the omission would have gone unnoticed until a third-party import appeared there, resolved from the root, and skewed. Shared *files* are now a first-class list beside the shared dirs. The allowlist was a blanket exemption by name. Each entry's rationale establishes that a patch/minor difference is benign, which is no evidence that a React 18-vs-19 or Hono 4-vs-5 split would be — that is a different type surface. Being listed now tolerates skew only *within a major version*; a cross-major skew fails like anything else. Deriving the major from the lockfile version keeps this automatic, with no per-package range to maintain and bump. A version that can't be parsed is treated as "cannot prove same major" and fails rather than passes. `verify-format-coverage.mjs`'s comment overstated the vouching graph: the three guards do not each check the others. This one checks both siblings; each sibling checks only this one. Described as it actually works. The AGENTS.md rule said to bump a shared dependency in "root + all four clients", but launcher declares no zod and `findSkew` ignores installs where a package is absent — so the rule as written asked for spurious dependencies. Reworded to every install that *declares* it, and mirrored in `.github/copilot-instructions.md`. The failure output follows: it no longer advises adding a package that is already allowlisted, saying "MAJOR skew" instead, and it names all three shared sources rather than the two dirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- scripts/verify-dep-lockstep.mjs | 85 ++++++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 85 +++++++++++++++++++++++++++- scripts/verify-format-coverage.mjs | 8 ++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a49cec610..38ae3ca97 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. -- **A dependency bump must land in every install.** 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 touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. +- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index 1d38a6e2a..2fcb3ab96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,7 +453,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **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 `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), 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. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. 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`. + - **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` / `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. diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0eb8a9dda..2e487fd89 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -21,8 +21,9 @@ // 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 -// `core/` and `test-servers/src`, the two first-party surfaces compiled into -// more than one client's program. Skew is then denied by default, with a small +// 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. @@ -45,11 +46,25 @@ const repoRoot = path.resolve( // `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 @@ -175,11 +190,34 @@ export function findSkew(candidates, installs) { return skewed; } -/** Split skewed packages into the tolerated ones and the failures. */ +/** + * 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) => !tolerated.has(s.name)), - ignored: skewed.filter((s) => tolerated.has(s.name)), + failures: skewed.filter((s) => !isTolerated(s)), + ignored: skewed.filter(isTolerated), }; } @@ -191,8 +229,12 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { // unnoticed until a new shared dependency arrived through one and skewed. const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; -/** Whether a repo-relative path is a TypeScript source of a shared tree. */ +/** + * 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. @@ -203,7 +245,12 @@ export function isSharedSourceFile(file) { function sharedSourceFiles() { const out = execFileSync( "git", - ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + [ + "ls-files", + "--", + ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), + ...SHARED_SOURCE_FILES, + ], { cwd: repoRoot, encoding: "utf8" }, ); return out.split("\n").filter(isSharedSourceFile); @@ -278,22 +325,36 @@ export function main() { 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) { - console.error(` ${name}`); + // 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_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + `\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 so all lockfiles agree — or, if this" + - "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + - "\nwith the reason. See AGENTS.md.", + "\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); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 890d90b57..557045789 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -10,6 +10,7 @@ import { findSkew, importedPackageNames, isSharedSourceFile, + majorOf, packageNameOf, partitionSkew, topLevelLockVersions, @@ -240,8 +241,20 @@ 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: [] }, - { name: "zod", holders: [] }, + { + 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); @@ -256,6 +269,72 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { }); test("partitionSkew: deny by default — nothing tolerated fails everything", () => { - const skewed = [{ name: "zod", holders: [] }]; + 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.mjs b/scripts/verify-format-coverage.mjs index 014b850fd..20ea65db4 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,9 +166,11 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guards: a guard can't detect being unrun itself, but the -// three can each assert the others are still wired into `validate`, so dropping -// any one is caught here. Only deleting all of them 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; From 2dcf1450b21e76be4d89d61c74ff300dffece25e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:07:37 -0400 Subject: [PATCH 37/93] chore(deps): see imports through comment trivia; name vitest.shared.mts in the README Both suppressed comments from Copilot's fourth pass of #1962. TypeScript allows a comment anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` and `from /* why */ "pkg"` are valid imports the patterns stepped over. That is the dangerous direction: a missed specifier never enters the candidate set, so the package's skew passes the guard silently, against the over-approximation the module claims. A `TRIVIA` fragment now stands in for `\s*` at every inter-token position, with regression cases for the commented static, dynamic, side-effect, and `require` forms. I did not move to the TypeScript parser. This is a root `scripts/` `.mjs` tool with no bundler and no TS dependency; pulling in a compiler API to read import specifiers is a lot of machinery for a scan that only needs to over-approximate, and each missed form is a one-line pattern with a test beside it. The derived set is unchanged at 18 real names, so nothing here widens what is checked today. The README's command row also still described the candidate scan as `core/` and `test-servers/src` only, omitting the `vitest.shared.mts` the guard now includes; it also never mentioned that an allowlisted package is tolerated only within a major. Both stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 24 ++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fef78256e..cf163f54a 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `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 `core/` and `test-servers/src` — which resolve 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 `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. 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). | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 2e487fd89..0a328e44b 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -130,11 +130,27 @@ export function packageNameOf(specifier) { // `tools`…" would otherwise enter the candidate set as a package named // `tools`. Inert today, but a prose word that collides with a real package // name would silently widen the set. +// +// Whitespace between tokens is really *trivia*: TypeScript allows a comment +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` +// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, +// #1962). `TRIVIA` stands in for `\s*` at every such position. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" - /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" - /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) - /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") + // import … from "x" / export … from "x" + new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), + // side-effect import "x" + new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), + // dynamic import("x"[, opts]) + new RegExp( + String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), + // import x = require("x"), require("x") + new RegExp( + String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 557045789..6d0840d70 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -100,6 +100,24 @@ test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, ]); }); +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: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From 2f1d97e7bbf4dc38de7cf0f47bab8fa74e202e07 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:13:56 -0400 Subject: [PATCH 38/93] chore(deps): treat line comments as trivia too in the import scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifth pass of #1962. `TRIVIA` covered block comments but not `//`, despite the contract right above it saying a comment is legal wherever whitespace is — so `import(// lazy\n"pkg")`, `require(// lazy\n"pkg")`, and `from // reason\n"pkg"` were stepped over, and such a package would never enter the candidate set. Same silent-miss direction as the block-comment gap. Both comment syntaxes are now covered. The line-comment branch runs to end-of-line only; the newline itself is matched by TRIVIA's `\s` branch. Regression test added alongside the block-comment one. The derived set is unchanged at 18 real names — widening trivia does not admit prose, since a specifier must still follow immediately and `packageNameOf` rejects anything not shaped like one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 10 ++++++---- scripts/verify-dep-lockstep.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0a328e44b..5d29497b9 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -132,10 +132,12 @@ export function packageNameOf(specifier) { // name would silently widen the set. // // Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` -// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, -// #1962). `TRIVIA` stands in for `\s*` at every such position. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, +// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) +// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for +// `\s*` at every such position and covers both comment syntaxes. A line comment +// runs to end-of-line only — the newline itself is matched by the `\s` branch. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; const SPECIFIER_FORMS = [ // import … from "x" / export … from "x" new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6d0840d70..eee81432c 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -118,6 +118,25 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () ]); }); +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 these are valid imports too. The newline is matched by TRIVIA's `\\s` + // branch rather than by the line-comment branch. + 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: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From fcd45037c9c012486f548ed43265c2319d79340a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:24:55 -0400 Subject: [PATCH 39/93] chore(deps): extract specifiers with ts.preProcessFile; document the transitive boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's sixth pass of #1962. Take the TypeScript-parser suggestion, which was raised across three rounds and was right. The regex scan got both directions wrong and every widening traded one failure for the other: it *missed* valid syntax (`import x = require(…)`, an import-attributes argument, a comment between tokens — each a silent miss, so the package never entered the candidate set and its skew passed), and it *invented* names from prose, since `// adapted from "react"` is indistinguishable from an import to a pattern that cannot tell code from a comment. The earlier prose test only passed because `cwd omitted` isn't a valid package name. `ts.preProcessFile` is the cheap answer I'd missed while arguing against the full AST: TypeScript's lightweight pre-parse scanner, no parse tree and no type checking, purpose-built to return module specifiers. It handles every import form, trivia, strings, and regex literals, and never reads a comment as code. typescript resolves from `clients/web`, which already carries it, via the `createRequire` base `smoke-web-browser.mjs` established — a bare import would resolve relative to `scripts/`, not the cwd. The derived set drops 18 → 17 real names: every genuine package is retained and the one prose artifact is gone. Regression tests now use *real* package names in comments and strings, the case the old test couldn't cover. Also records the KNOWN BOUNDARY the review's other finding identified. Candidates cover what the shared sources name directly, so a package reaching the program only through another package's `.d.ts` is invisible — `@modelcontextprotocol/sdk` is the live example, skewed 1.29.0 vs 1.30.0 and present in web's program from both installs, never written in first-party code. Closing it properly changes what the guard measures, so it is tracked as #1965, with both candidate derivations measured there: a lockfile closure is unusable (155 packages, 25 skewed, nearly all irrelevant tooling, and it misses the SDK anyway), while `tsc --listFilesOnly` is correct and small (15 for clients/web). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 99 +++++++++++++++------------- scripts/verify-dep-lockstep.test.mjs | 26 ++++---- 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 5d29497b9..4eb32d9a4 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -27,10 +27,25 @@ // 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 } from "node:module"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { rootReachesScript } from "./lib/npm-scripts.mjs"; @@ -116,59 +131,51 @@ export function packageNameOf(specifier) { return name; } -// Every form that can introduce a dependency's *types* (Copilot, #1962). -// `import x = require("pkg")` and a bare `require("pkg")` matter because the -// shared trees may hold `.cts` sources, where that is the ordinary import -// syntax; and neither call form requires the closing paren, so an import -// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// 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. // -// Backticks are accepted ONLY in the call forms, where a static template -// literal is legal. A `from` clause requires a string literal, so allowing -// backticks there would buy nothing while reopening the prose hazard the -// anchored specifier pattern exists to close — inline code in a comment is -// written with backticks throughout this codebase, and "…derived from -// `tools`…" would otherwise enter the candidate set as a package named -// `tools`. Inert today, but a prose word that collides with a real package -// name would silently widen the set. +// `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. // -// Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, -// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) -// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for -// `\s*` at every such position and covers both comment syntaxes. A line comment -// runs to end-of-line only — the newline itself is matched by the `\s` branch. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; -const SPECIFIER_FORMS = [ - // import … from "x" / export … from "x" - new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), - // side-effect import "x" - new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), - // dynamic import("x"[, opts]) - new RegExp( - String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), - // import x = require("x"), require("x") - new RegExp( - String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), -]; +// 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"), + ); + tsCache = require_("typescript"); + } + return tsCache; +} /** * Every third-party package name imported by a blob of TypeScript source. - * Deliberately a regex scan rather than a parse: it only needs to over- rather - * than under-approximate, since a name absent from every lockfile contributes - * nothing downstream (`@inspector/core` is a build-time alias, not a package, - * and drops out that way). + * 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(); - for (const re of SPECIFIER_FORMS) { - for (const m of source.matchAll(re)) { - const name = packageNameOf(m[1]); - if (name) names.add(name); - } + // (source, readImportFiles, detectJavaScriptImports) — the latter two make it + // report `require(…)` and dynamic imports as well as static ones. + const { importedFiles } = typescript().preProcessFile(source, true, true); + for (const { fileName } of importedFiles) { + const name = packageNameOf(fileName); + if (name) names.add(name); } return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index eee81432c..6fd9ecacd 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -155,26 +155,26 @@ test("importedPackageNames: the three specifier forms that introduce types", () ]); }); -test("importedPackageNames: prose after `from` is not an import", () => { - // The scan over-approximates on purpose, but must not invent package names - // out of comment text — a bogus name absent from every lockfile is inert, - // yet a *plausible* one would silently widen the candidate set. +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 = ` - // Resolved relative to the runner dir, not from "cwd omitted" by the caller. - /** Reads the manifest from "file:" URLs. */ + // 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 backticked `from` in prose is not an import", () => { - // Backticks are legal in the *call* forms (a static template literal) but - // never after `from`, which requires a string literal. Accepting them there - // would sweep in inline code from comments — this codebase writes it with - // backticks throughout — and a prose word colliding with a real package name - // would silently widen the candidate set. +test("importedPackageNames: a specifier inside a string literal is not an import", () => { const source = ` - /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + const msg = 'run require("chokidar") to load it'; + const re = /"jose"/; import { z } from "zod"; `; assert.deepEqual([...importedPackageNames(source)], ["zod"]); From 3638185e0ffa489419b6e4247c1431374ea500f0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:33:37 -0400 Subject: [PATCH 40/93] chore(deps): fail with a cause when typescript can't be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard resolves typescript from `clients/web`. The realistic way for that to fail is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, which leaves `clients/web/node_modules` empty — and a bare MODULE_NOT_FOUND stack gives no hint that the fix is `npm install` at the root. Throw with that guidance and the original error as `cause`. Deliberately not a silent skip: an unrun guard guards nothing, which is the failure mode the sibling-vouch ring exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 4eb32d9a4..8d20c206c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -156,7 +156,20 @@ function typescript() { const require_ = createRequire( path.join(repoRoot, "clients", "web", "package.json"), ); - tsCache = require_("typescript"); + 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; } From 48d74ba423e83b8d6ad3f207913e42bfa055c28f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:41:31 -0400 Subject: [PATCH 41/93] chore(deps): check each shared source contributed, not the aggregate count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eighth pass of #1962. The no-op guard tested only `files.length === 0`, which stopped meaning anything once `SHARED_SOURCE_FILES` was added: `vitest.shared.mts` alone keeps the total nonzero, so a moved or renamed `core/` would leave the guard deriving candidates from a near-empty set and passing on skew it exists to catch. `sourcesWithNoFiles` now requires every configured dir and every named file to have contributed, and names the ones that didn't. Directory matching is on a path separator, so a prefix sibling (`core-internal/`) can't vouch for a renamed `core/`. Also drops two test comments left stale by the move to `ts.preProcessFile` — they still described the deleted TRIVIA regex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 31 +++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 46 ++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 8d20c206c..de4cccf0a 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -279,6 +279,23 @@ export function isSharedSourceFile(file) { 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( @@ -334,9 +351,19 @@ export function main() { } const files = sharedSourceFiles(); - if (files.length === 0) { + // 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( - `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + "\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); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6fd9ecacd..89268ee41 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -13,6 +13,7 @@ import { majorOf, packageNameOf, partitionSkew, + sourcesWithNoFiles, topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; @@ -47,6 +48,46 @@ test("isSharedSourceFile: non-TS files and other trees are excluded", () => { 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"], @@ -120,8 +161,7 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () 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 these are valid imports too. The newline is matched by TRIVIA's `\\s` - // branch rather than by the line-comment branch. + // so a specifier can sit on the next line and these are still valid imports. const source = [ "import { a } from // reason", ' "express";', @@ -137,7 +177,7 @@ test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962) ]); }); -test("importedPackageNames: the three specifier forms that introduce types", () => { +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'; From 2f2d545751fee1e9710d26b460e58411896808fb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:48:13 -0400 Subject: [PATCH 42/93] docs: narrow the dep-lockstep summaries to the invariant it actually proves From Copilot's ninth pass of #1962. The one-line descriptions in AGENTS.md and the README claimed "no install-crossing dependency resolves to two different versions", which is stronger than the gate: candidates cover only packages the shared sources name directly, and #1965 records a live counterexample (`@modelcontextprotocol/sdk`, skewed across two install roots while the guard stays green). The boundary was already documented at length on the guard and in the AGENTS.md rule; the summaries just hadn't been narrowed to match, which is how a doc ends up asserting a guarantee nobody implemented. Both now say "directly imported" and point at #1965. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fcb3ab96..3da1011e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### 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 **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), 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). +- 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). diff --git a/README.md b/README.md index cf163f54a..126c76a06 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | 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 install-crossing dependency skews) — 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 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`. | From 32c827f0895b3db0d3948d73158dc741b3576d4a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:55:49 -0400 Subject: [PATCH 43/93] test(scripts): exercise the dep-lockstep guard's executable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's tenth pass of #1962. The suite covered only pure helpers, so a regression in source enumeration, install discovery, lockfile loading, the sibling-guard vouch, or the nonzero exit on real skew would have left it green — and the negative cases had only ever been run by hand, once per review round. Five end-to-end cases build a throwaway repo and run the guard as a subprocess: aligned installs exit 0; a skewed one exits 1 naming the package and every holder while leaving the agreeing package unreported; a package imported only by `test-servers/src` still fails (so dropping the second shared tree can't pass); a shared source matching no tracked file fails; and a `validate` chain that stopped running `verify:format-coverage` fails the vouch even when the versions are fine. The fixture must be realpath'd. On macOS `tmpdir()` is `/var/…`, a symlink to `/private/var/…`, and the guard runs `main()` only when `import.meta.url` (the resolved path) matches `process.argv[1]` — so launching it via the unresolved path loads the module and does nothing, exiting 0 silently. That is how all five first "passed" as no-ops, and the comment records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 203 ++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/verify-dep-lockstep.main.test.mjs diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs new file mode 100644 index 000000000..6321d24e8 --- /dev/null +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -0,0 +1,203 @@ +// 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", + ), +); + +const lock = (deps) => ({ + lockfileVersion: 3, + packages: 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 }) { + // 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", 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 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`/); + }, + ); +}); From af63a078179c3bb51be7f3667928db5e4341785a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:02:56 -0400 Subject: [PATCH 44/93] chore(deps): reject an unreadable lockfile instead of failing open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eleventh pass of #1962, and the most consequential kind of bug for a gate: it failed *open*. `topLevelLockVersions` returns an empty map for anything without a v2+ `packages` table — a lockfileVersion 1 file, or a malformed one. `main()` fed that straight into the comparison, so such an install contributed no holders at all, and a genuine skew among the remaining installs was reported as aligned. The guard would have said OK to exactly what it exists to catch. `hasReadableLockShape` now gates every discovered lockfile before any comparison, requiring a `packages` object carrying the `""` root entry npm always writes — "has a packages key" is not enough. An unreadable one is named and exits 1, telling you to regenerate it. A JSON parse failure likewise reports the file rather than dying on a bare SyntaxError with no path in it. The empty-map behavior of the pure helper is kept and its test now says why it is safe: the shape check rejects those inputs before they can reach `findSkew`. Three regression cases: a v1 lockfile in a fixture that IS skewed (so failing open would show as a pass), a `packages` table with no root entry, and the unit table for the predicate. Fixtures now write the `""` root entry, matching real npm output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 62 ++++++++++++++++++++--- scripts/verify-dep-lockstep.mjs | 60 +++++++++++++++++++--- scripts/verify-dep-lockstep.test.mjs | 29 +++++++++++ 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 6321d24e8..50db6b994 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -37,14 +37,18 @@ const typescriptDir = path.dirname( ), ); +/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ const lock = (deps) => ({ lockfileVersion: 3, - packages: Object.fromEntries( - Object.entries(deps).map(([name, version]) => [ - `node_modules/${name}`, - { version }, - ]), - ), + packages: { + "": { name: "fixture" }, + ...Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), + }, }); /** @@ -52,7 +56,7 @@ const lock = (deps) => ({ * install and one client install, and the guard itself. `rootDeps`/`webDeps` * decide whether the two installs agree. */ -function makeFixture({ rootDeps, webDeps, scripts }) { +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 @@ -88,7 +92,7 @@ function makeFixture({ rootDeps, webDeps, scripts }) { ); write("package-lock.json", lock(rootDeps)); write("clients/web/package.json", { name: "web" }); - write("clients/web/package-lock.json", lock(webDeps)); + 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. @@ -182,6 +186,48 @@ test("main: exits 1 when a configured shared source matches no file", () => { }); }); +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 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index de4cccf0a..ab928835f 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -209,6 +209,25 @@ export function topLevelLockVersions(lock) { 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) { + 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 @@ -375,13 +394,42 @@ export function main() { } const dirs = installDirs(); - const installs = dirs.map((dir) => ({ + 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( - JSON.parse( - readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), - ), - ), + versions: topLevelLockVersions(lock), })); const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 89268ee41..79974404f 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -8,6 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { findSkew, + hasReadableLockShape, importedPackageNames, isSharedSourceFile, majorOf, @@ -243,10 +244,38 @@ test("topLevelLockVersions: nested duplicates are ignored", () => { }); 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, + {}, + { packages: null }, + { packages: [] }, // an array has no `""` key + { packages: {} }, // no root entry + { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + ]; + 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"]]) }, From 87c1e0542046d9376d08f8d3300ba1a2b76b9d30 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:09:42 -0400 Subject: [PATCH 45/93] chore(deps): count triple-slash type references as candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's twelfth pass of #1962. A `/// <reference types="x" />` pulls in a package's declarations exactly like an import does, but TypeScript reports it in `preProcessFile`'s `typeReferenceDirectives`, not `importedFiles` — and only the latter was read. A shared source using one could therefore load the root's copy of a package alongside a client's without it ever entering the candidate set, so its skew would pass. Same silent-miss direction as the earlier syntax gaps, through a channel the parser switch didn't cover because I only read one of its two outputs. `typeReferencePackageNames` maps a directive to both candidates, since the name is the *type* name and not the 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. Scoped names mangle `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. `path` references name a file, not a package, and are ignored. The real candidate set is unchanged at 17: no shared source uses a triple-slash reference today. As with the other syntax fixes, this closes the channel against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 30 +++++++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 36 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index ab928835f..22ebb9ad7 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -175,7 +175,30 @@ function typescript() { } /** - * Every third-party package name imported by a blob of TypeScript source. + * 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 @@ -185,11 +208,14 @@ 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 } = typescript().preProcessFile(source, true, true); + 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; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 79974404f..a00ce642a 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -16,6 +16,7 @@ import { partitionSkew, sourcesWithNoFiles, topLevelLockVersions, + typeReferencePackageNames, } from "./verify-dep-lockstep.mjs"; test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { @@ -196,6 +197,41 @@ test("importedPackageNames: static, side-effect, and dynamic forms; builtins and ]); }); +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 From 8a2035c4d1145801914671b48f85d25bebe4a7bf Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:16:29 -0400 Subject: [PATCH 46/93] test(scripts): cover verify-format-coverage's sibling-guard vouch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's thirteenth pass of #1962. This PR added `verify:dep-lockstep` to the vouch loop in `verify-format-coverage.mjs` without a test for it. The existing tests exercise `rootReachesScript` in isolation and the opposite direction (dep-lockstep vouching for format-coverage), so a typo in a sibling's name here would have left `test:scripts` green while that guard silently stopped being enforced — the same "a gate that stops gating" failure the vouch cycle exists to prevent, one level up. Three cases, reusing the fixture-repo approach: dropping either sibling from `validate` exits 1 naming that sibling, and with both wired no sibling is reported missing. The last asserts on the *reason* rather than the exit status, since the fixture has no client manifests to harvest globs from and so fails later for an unrelated reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-format-coverage.main.test.mjs | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/verify-format-coverage.main.test.mjs 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/); +}); From e1768e44cebba8c3ca41a0e589e8a70af7c2f6f8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:23:32 -0400 Subject: [PATCH 47/93] chore(deps): check the declared lockfileVersion, not just the packages key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fourteenth pass of #1962. `hasReadableLockShape` and the diagnostic it drives both promise "lockfileVersion 2+", but the check only looked for a `packages` table with a root entry. A file declaring v1 while carrying such a table was accepted, produced an empty version map, and could make a real skew read as aligned once only one other holder remained — the same fail-open the shape check was added to close, through the half of the contract that wasn't enforced. The declared version is now verified: a finite number, 2 or greater. Absent, non-numeric, and declared-v1-with-packages cases are all in the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 9 ++++++++- scripts/verify-dep-lockstep.test.mjs | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 22ebb9ad7..555533750 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -249,7 +249,14 @@ export function topLevelLockVersions(lock) { * cannot read it, so it must say so rather than skip the install. */ export function hasReadableLockShape(lock) { - const packages = lock?.packages; + // 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, ""); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index a00ce642a..55ca3b6f6 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -302,11 +302,20 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot undefined, null, {}, - { packages: null }, - { packages: [] }, // an array has no `""` key - { packages: {} }, // no root entry - { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { 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)); From 288adbcfd621a53edc0115653a8d81836a24c750 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:31:05 -0400 Subject: [PATCH 48/93] chore(deps): enrol installs by package.json, and fail on a missing lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifteenth pass of #1962. `installDirs` filtered on the presence of a lockfile, so a missing one silently removed that install from the comparison rather than failing. For the root — the install every shared source resolves from — that meant the guard could report success from client locks alone, which is the deny-by-default contract inverted. Enrolment is now by `package.json` (an install we are meant to compare), with the root always enrolled, and a missing lockfile is a named, loud failure. A `clients/` directory with no `package.json` is not an install and is skipped, so a stray scratch dir isn't asked for a lockfile it should never have. Three regression cases: a missing client lockfile in a fixture that IS skewed (so failing open would show as a pass), a missing ROOT lockfile, and a stray `clients/` dir that must not be enrolled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 41 +++++++++++++++++++++++ scripts/verify-dep-lockstep.mjs | 34 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 50db6b994..c00379d75 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -228,6 +228,47 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () ); }); +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( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 555533750..27b5812c9 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -377,9 +377,18 @@ function installDirs() { .map((e) => `clients/${e.name}`) .sort() : []; - return ["."] - .concat(clients) - .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); + // 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")), + ), + ); } /** @@ -427,6 +436,25 @@ export function main() { } 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; From 719da6fbdd908187ad0e8c6df3d29406bdb3d590 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:21:15 -0400 Subject: [PATCH 49/93] ci: gate claude.yml on same-repo PRs and pin actions to commit SHAs (#1882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of hardening on the Claude Code workflow, plus a comment for a finding that needed no change. Same-repo gating. `Get PR details` now emits an `is_fork` output, and the PR-head checkout and the `Run Claude Code` step are both gated on it. A fork PR is declined outright — no checkout, no agent run, with the reason written to the step summary — rather than falling through to a "metadata-only" review of the base tree, which would trade an untrusted-code problem for a wrong-tree one. A deleted fork (`head.repo` null) counts as a fork. The head checkout also drops its `repository:` input: only a same-repo head reaches that step now, and checkout's default `github.repository` is a value no PR can influence. SHA pins. actions/checkout, actions/github-script, and claude-code-action were all on mutable major tags in a job that holds ANTHROPIC_API_KEY and grants the agent Bash. Each is now pinned to a full commit SHA with the trailing `# vX.Y.Z` comment Dependabot reads, so upgrade automation is unaffected. Pinned to the v7 / v9 SHAs so this does not regress the bumps in the open Dependabot PR #1922. The failure fallback Copilot flagged turns out not to exist: `Checkout repository` carries no status-check function, so GitHub applies an implicit `success()` and skips it after a failed lookup regardless of the `outcome` comparison. Its condition is now the explicit `== 'skipped'` and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .github/workflows/claude.yml | 53 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) 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 }} From 0325d81493eda089afc40af089b3d8028859dfd9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:25:33 -0400 Subject: [PATCH 50/93] fix: open the modern listen stream whenever the filter is non-empty (#1920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the modern (2026-07-28) era every server→client notification rides the `subscriptions/listen` stream, but the Inspector only ever opened that stream from a resource-subscribe click. A tools-only server advertising `tools.listChanged` therefore had no path to an open stream, so `notifications/tools/list_changed` could never arrive — even though `buildSubscriptionFilter()` already modelled the opt-in. The trigger now matches the filter it builds: the stream opens (and stays open, and reconnects) whenever the built filter carries anything — subscribed URIs or an enabled list-change opt-in the server advertises. That mirrors the SDK's own `ClientOptions.listChanged` auto-open, which opens on a non-empty config ∩ capability intersection. `connect()` opens it once the handlers are registered; a failure there is handed to the reconnect machinery rather than failing the connect. `ResourceSubscriptionStreamState.active` deliberately keeps its narrower meaning — "at least one URI is subscribed" — because it drives the Subscriptions section's badge, which has nothing to report for a listChanged-only stream. That also preserves the invariant the rest of the file is written against: an empty subscribed set is never announced alongside an `active` stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../ResourceControls/ResourceControls.tsx | 4 +- .../inspectorClient-subscriptions-era.test.ts | 218 +++++++++++++++++- core/mcp/inspectorClient.ts | 116 ++++++++-- core/mcp/types.ts | 11 +- 4 files changed, 321 insertions(+), 28 deletions(-) diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 8d4e95900..adf5aa1a5 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -52,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. */ 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..6d1828173 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[] = []; @@ -125,15 +146,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 +246,145 @@ 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 = connected as unknown as { + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + }; + 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("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). + ( + connected as unknown as { + refreshModernSubscription: () => Promise<void>; + } + ).refreshModernSubscription = () => + 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", + ); + }); + }); + describe("legacy era", () => { it("subscribes via resources/subscribe with no listen stream", async () => { const started = await startServer(undefined); @@ -355,8 +543,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 +701,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 +836,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/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8e2684778..31a7e5e3e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -479,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 @@ -2056,6 +2059,14 @@ 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(); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; @@ -4893,9 +4904,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 @@ -4917,6 +4932,69 @@ 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. + */ + private async openModernListenStreamOnConnect(): Promise<void> { + if (!this.isModernEra() || !this.wantsModernStream()) return; + try { + await this.refreshModernSubscription(); + } catch (error) { + this.logger.error( + { error }, + "Failed to open the modern subscriptions/listen stream on connect", + ); + this.reconcileModernStreamStateAfterFailedRefresh(); + } + } + /** Cancel a pending reconnect re-listen, if any (#1630). */ private clearModernReconnectTimer(): void { if (this.modernReconnectTimer !== undefined) { @@ -4927,9 +5005,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 @@ -4955,8 +5034,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; } @@ -4978,7 +5057,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 ?? [], }); @@ -5024,7 +5103,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 @@ -5033,7 +5112,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: [], }); @@ -5073,7 +5152,7 @@ export class InspectorClient extends InspectorClientEventTarget { * 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; } @@ -5088,7 +5167,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ private scheduleModernReconnect(): void { this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "reconnecting", honoredUris: [], }); @@ -5101,10 +5180,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(() => @@ -5123,10 +5199,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/types.ts b/core/mcp/types.ts index 52840af8a..b89f117b2 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -436,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; From eda8ec0db8afdf0543567019c0771bdf0d3f66bd Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:34:19 -0400 Subject: [PATCH 51/93] fix: guard the connect-time listen failure on the refresh generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: `openModernListenStreamOnConnect`'s catch reconciled unconditionally, unlike the subscribe/unsubscribe call sites. The `connect` event is dispatched before the stream opens, so a listener subscribing to a resource can start and acknowledge a newer refresh while this one is still awaiting its `listen()` — and reconciling anyway would arm a reconnect that tears down a healthy stream. Apply the same generation test. Also consolidates the tests' private-member access onto the file's existing `internals()` helper (one justified double cast, hoisted and documented) and makes the failure stub bump the generation the way the real method does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 102 ++++++++++++------ core/mcp/inspectorClient.ts | 12 ++- 2 files changed, 80 insertions(+), 34 deletions(-) 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 6d1828173..91daa6388 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 @@ -124,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({}); @@ -322,15 +350,7 @@ describe("resource subscriptions era fork (#1630)", () => { const started = await startToolsOnlyServer({ tools: true }); const { connected } = await connect(started.url, "modern"); - const int = connected as unknown as { - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - }; + const int = internals(connected); const dropped = int.modernSubscription; expect(dropped).not.toBeNull(); if (!dropped) return; @@ -367,13 +387,15 @@ describe("resource subscriptions era fork (#1630)", () => { ); // 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). - ( - connected as unknown as { - refreshModernSubscription: () => Promise<void>; - } - ).refreshModernSubscription = () => - Promise.reject(new Error("listen boom")); + // `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; @@ -383,6 +405,37 @@ describe("resource subscriptions era fork (#1630)", () => { "reconnecting", ); }); + + it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { + // `connect` is dispatched before the stream is opened, so a listener can + // start and acknowledge a newer refresh while this one is still awaiting + // its `listen()`. Reconciling anyway would arm a reconnect against a + // healthy stream and tear it down — the same ownership test the + // subscribe/unsubscribe paths make. + 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", () => { @@ -433,23 +486,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; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 31a7e5e3e..c9ec82071 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4981,9 +4981,17 @@ export class InspectorClient extends InspectorClientEventTarget { * 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. This call is *not* the only refresh that can be in flight: + * the `connect` event has already been dispatched, so a listener subscribing + * to a resource can start and acknowledge a newer refresh while this one is + * still awaiting its `listen()`. Reconciling unconditionally would then arm a + * reconnect that tears down a stream that is up and healthy. */ private async openModernListenStreamOnConnect(): Promise<void> { if (!this.isModernEra() || !this.wantsModernStream()) return; + const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { @@ -4991,7 +4999,9 @@ export class InspectorClient extends InspectorClientEventTarget { { error }, "Failed to open the modern subscriptions/listen stream on connect", ); - this.reconcileModernStreamStateAfterFailedRefresh(); + if (this.modernListenGeneration === generationBefore + 1) { + this.reconcileModernStreamStateAfterFailedRefresh(); + } } } From ca70a7cfa7d7860e7e4f89b9d2f51d2b5aa78edb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:46:32 -0400 Subject: [PATCH 52/93] chore(storybook): drop the redundant setProjectAnnotations setup file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Storybook 10.3, @storybook/addon-vitest provisions the preview annotations itself — and *skips* doing so when it finds a setup file calling setProjectAnnotations. So .storybook/vitest.setup.ts was not merely redundant, it was actively opting the project out of the automatic path, which the addon printed a notice about on every run. Remove the file and the `setupFiles` entry from the `storybook` vitest project. A green suite doesn't prove the automatic provisioning works: without the preview annotations, stories would render outside MantineProvider and without App.css, and would very likely still pass. Add src/test/PreviewAnnotations.stories.tsx, which asserts from a play function that the Mantine theme variables and the App.css tokens are actually present in the rendered document, so an unthemed render fails loudly. Verified by emptying `decorators` in preview.tsx and confirming the story goes red. The a11y addon's axe runner can't be introspected from a play function, so it was checked out-of-band: a story with a real violation turned the suite red with the setup file already removed, confirming @storybook/addon-a11y/preview is applied automatically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- clients/web/.storybook/vitest.setup.ts | 7 -- .../src/test/PreviewAnnotations.stories.tsx | 78 +++++++++++++++++++ clients/web/vite.config.ts | 10 ++- 3 files changed, 87 insertions(+), 8 deletions(-) delete mode 100644 clients/web/.storybook/vitest.setup.ts create mode 100644 clients/web/src/test/PreviewAnnotations.stories.tsx 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/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx new file mode 100644 index 000000000..50cafe5da --- /dev/null +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -0,0 +1,78 @@ +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). + +// The primary shade the theme pins for the light scheme — the value Mantine +// derives `--mantine-primary-color-filled` from. Read from the theme rather +// than hard-coded so a palette change can't silently invalidate the guard. +const LIGHT_PRIMARY_SHADE = 7; + +function expectedPrimaryColor(): string { + const palette = theme.colors?.[theme.primaryColor ?? ""]; + const color = palette?.[LIGHT_PRIMARY_SHADE]; + 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/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. }, }, ], From d08a53027a3e6bdeea3d091af6a49ac2cdef5919 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:49:20 -0400 Subject: [PATCH 53/93] fix: dispatch the connect event after the listen stream is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2: opening the stream after `dispatchTypedEvent("connect")` left a real gap. The managed list states start their initial `refresh()` from that event, so `tools/list` could go out ahead of `subscriptions/listen` — and a list the server changed in that window would notify nobody, leaving the UI stale with no way to notice. The handler registration, the initial logging level, and the stream open now all run before the `connect` dispatch, so no consumer can act on the connection until the notification channel is established (or its retry armed). Costs one listen round-trip on a modern connect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 24 +++++++++++++++++++ core/mcp/inspectorClient.ts | 11 +++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) 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 91daa6388..9c61266f5 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 @@ -373,6 +373,30 @@ describe("resource subscriptions era fork (#1630)", () => { ); }); + 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("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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index c9ec82071..4f9993a43 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,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( @@ -2067,6 +2065,15 @@ export class InspectorClient extends InspectorClientEventTarget { // 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. + this.dispatchTypedEvent("connect"); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; From d1135dfe7c425a021a7e7cdf6811c97157d5e801 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:54:44 -0400 Subject: [PATCH 54/93] chore(storybook): derive the primary shade from the theme in the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard restated `primaryShade.light` as a literal 7, so re-pinning the theme's primary shade would have made it fail even with the preview annotations correctly applied — a guard that has to be edited alongside a legitimate theme change just trains people to edit it. Derive the shade from `theme.primaryShade`, handling both of Mantine's forms (a bare number and `{ light, dark }`) and falling back to Mantine's own default of 6 when a theme pins none. Verified by temporarily re-pinning the theme to `{ light: 8, dark: 8 }`: the guard still passes, where the hard-coded 7 would have failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../src/test/PreviewAnnotations.stories.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx index 50cafe5da..bca4fb481 100644 --- a/clients/web/src/test/PreviewAnnotations.stories.tsx +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -30,14 +30,29 @@ import { theme } from "../theme/theme"; // 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). -// The primary shade the theme pins for the light scheme — the value Mantine -// derives `--mantine-primary-color-filled` from. Read from the theme rather -// than hard-coded so a palette change can't silently invalidate the guard. -const LIGHT_PRIMARY_SHADE = 7; +// 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?.[LIGHT_PRIMARY_SHADE]; + const color = palette?.[lightPrimaryShade()]; if (!color) throw new Error("theme is missing its primary color palette"); return color; } From b2dfd4099ab6a1d9615eb9298993b56194456540 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:56:24 -0400 Subject: [PATCH 55/93] docs: correct three comments the connect-ordering change invalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3 (no new code findings, three stale-comment ones): - `openModernListenStreamOnConnect`'s generation guard was justified by a `connect` listener racing it — which the previous commit made impossible by dispatching `connect` after this call. The guard is still right, for reasons that survive the reorder: `statusChange` has already fired, and a concurrent `subscribeToResource` or `disconnect()` (whose `resetSubscriptionStream` bumps the generation) can supersede it. - `reconcileModernStreamStateAfterFailedRefresh`'s JSDoc still described its branches as "nothing subscribed" vs "URIs live"; they are the empty vs non-empty *filter* now, which differ exactly on the listChanged-only path. - The superseded-failure test carried the same stale `connect` rationale. Comments only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 14 ++++++---- core/mcp/inspectorClient.ts | 26 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) 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 9c61266f5..16289fc3b 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 @@ -431,11 +431,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { - // `connect` is dispatched before the stream is opened, so a listener can - // start and acknowledge a newer refresh while this one is still awaiting - // its `listen()`. Reconciling anyway would arm a reconnect against a - // healthy stream and tear it down — the same ownership test the - // subscribe/unsubscribe paths make. + // 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 }, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 4f9993a43..d36f71430 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4990,11 +4990,14 @@ export class InspectorClient extends InspectorClientEventTarget { * with backoff and settles on `"ended"` past the cap. * * Gated on the same generation test as `subscribeToResource` — see the long - * comment there. This call is *not* the only refresh that can be in flight: - * the `connect` event has already been dispatched, so a listener subscribing - * to a resource can start and acknowledge a newer refresh while this one is - * still awaiting its `listen()`. Reconciling unconditionally would then arm a - * reconnect that tears down a stream that is up and healthy. + * 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; @@ -5148,10 +5151,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 @@ -5159,11 +5163,11 @@ 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. From 2a4e93c621ac58928a85811acff07acfe68f1f40 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 00:05:56 -0400 Subject: [PATCH 56/93] fix: only announce the connection if it is still live after the stream open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4: every await before the `connect` dispatch is a window for a `disconnect()` or a transport close to overtake the connect, and the listen round-trip widened it. Announcing anyway restarts every managed list refresh against a session being torn down or already dead. The dispatch is now gated on `status === "connected" && !disconnecting` — the second because an explicit teardown claims ownership before it awaits `client.close()`, leaving the status untouched until that block finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 46 +++++++++++++++++++ core/mcp/inspectorClient.ts | 12 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) 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 16289fc3b..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 @@ -397,6 +397,52 @@ describe("resource subscriptions era fork (#1630)", () => { 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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d36f71430..b0b809f3f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -2073,7 +2073,17 @@ export class InspectorClient extends InspectorClientEventTarget { // 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. - this.dispatchTypedEvent("connect"); + // + // …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"; From 31c41d3ad61f1a8c0ff9eade0177e93fe8c3f018 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:07:04 -0400 Subject: [PATCH 57/93] chore: declare the MCP SDK packages only at the repo root (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four v2 SDK packages (`client`, `core`, `server`, `server-legacy`) plus `ext-apps` were declared in the root manifest *and* again in clients/web, cli, and tui, so each client installed its own copy. Node resolution walks up and the root install is on every client's chain — and the root manifest is already the documented source of truth for what ships — so the per-client entries were duplicates of a decision made at the root, and they had already drifted: before this change the tree carried ext-apps 1.7.4 at the root and 1.7.5 under clients/web, dragging in two copies of the v1 `@modelcontextprotocol/sdk` (1.29.0 and 1.30.0) through ext-apps' peer dependency. Nothing imports the v1 SDK; it is not in any manifest of ours, only a peer of ext-apps, so consolidating ext-apps is the only lever over it. After a clean install there is now exactly one copy of each, at the root. `npm update` then moves ext-apps to 1.7.5 and its SDK peer to 1.30.0, whose widened `@hono/node-server` range (`^1.19.9 || ^2.0.5`) also drops a nested duplicate of that package. `express` needed a real home to make this work. `test-servers/src` imports it, but no manifest declared it: it was reaching `clients/cli/node_modules` only as a peer of `express-rate-limit` under `@modelcontextprotocol/server-legacy`, so removing that entry took express with it and every cli test that spawns a test server failed to resolve it. It is now a root devDependency — test-servers is root-owned code with no manifest of its own — and `vitest.shared.mts` resolves it from the root, joining `yaml`, which was already the precedent for exactly this case. The clients/web duplicate is dropped; its only reference is a type-only import covered by `@types/express`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- clients/cli/package-lock.json | 1022 +------------------------------ clients/cli/package.json | 4 - clients/tui/package-lock.json | 77 +-- clients/tui/package.json | 2 - clients/web/package-lock.json | 1075 +-------------------------------- clients/web/package.json | 6 - package-lock.json | 86 +-- package.json | 1 + vitest.shared.mts | 13 +- 9 files changed, 50 insertions(+), 2236 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e44..5db50d28e 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", @@ -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..fedd355c4 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", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3ea..9b7de4d5a 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", @@ -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..18f71e8a9 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", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7e..ace9657b5 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", @@ -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" - } + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "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", @@ -11685,16 +10640,6 @@ "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..66ad0d4cb 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", @@ -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/package-lock.json b/package-lock.json index 58f21e7a3..81d9bd035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,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", @@ -330,9 +331,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 +360,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,19 +400,6 @@ } } }, - "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", @@ -1267,7 +1255,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 +1405,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 +1466,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 +1479,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 +1586,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 +1617,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 +1626,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 +1748,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 +1761,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 +1774,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 +1795,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 +1804,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 +1813,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" }, @@ -1859,8 +1834,7 @@ "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 +2042,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 +2072,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", @@ -2242,7 +2214,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 +2273,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 +2282,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 +2305,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 +2326,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 +2350,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 +2389,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 +2401,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 +2413,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" }, @@ -2673,7 +2636,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,8 +2730,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 + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "2.1.0", @@ -3146,7 +3107,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 +3116,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 +3125,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 +3137,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 +3146,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 +3218,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 +3236,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 +3257,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 +3269,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 +3361,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 +3398,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 +3542,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 +3565,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 +3586,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 +3714,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 +3776,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 +3802,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 +3848,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 +3867,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 +3883,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 +3901,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", @@ -4214,7 +4154,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,8 +4399,7 @@ "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", diff --git a/package.json b/package.json index d3467cf6d..f165824a4 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,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/vitest.shared.mts b/vitest.shared.mts index 060e099f2..f52919cc2 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 root-owned code that has no manifest + // of its own — `test-servers/src` imports express, and `core`/the root + // tooling uses yaml — 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$/, From 51bbe9996b8b051812c888ebcda33ba688366439 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:15:08 -0400 Subject: [PATCH 58/93] fix(web): keep the text being typed in a schema number field (#1888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SchemaForm` drove Mantine's `NumberInput` directly off the parent's numeric value. `NumberInput` reports the raw string while the text does not parse, so pressing `.` after `1` produced `"1."`, which the handler collapsed to `undefined` — and because the control is fully controlled, the `value` prop immediately rewrote the box back to `1`. The decimal point was discarded on every keystroke, making `1.5` unreachable. A trailing zero, a lone leading `-`, and an exponent failed the same way. Introduce `SchemaNumberInput`, which holds the in-progress text as the source of truth for what is displayed while the parent still only ever sees a `number | undefined`. Draft and value re-sync only when they genuinely diverge, via `useValueChange` (adjust-state-during-render) rather than an effect, so an external reset still rewrites the box but an in-progress `"1."` is left alone. Also pass `allowDecimal` from the schema, so an `integer` field rejects the decimal point instead of accepting a value its schema forbids — matching what the TUI already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../groups/SchemaForm/SchemaForm.test.tsx | 135 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 87 ++++++++++- 2 files changed, 216 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c534fb29f..a381add1f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; @@ -228,6 +229,140 @@ 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("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..0e67d854d 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,6 +51,79 @@ function toEnumData( return values; } +/** + * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the + * field. It emits a `number` once the text parses cleanly, and the **raw string** + * while it does not — `""` when cleared, but also the in-progress `"1."`, `"-"`, + * and `"1e"`. Anything that is not a finite number becomes `undefined`, which is + * how an absent optional argument is represented everywhere else in this form. + */ +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); + return Number.isFinite(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. + */ +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>; @@ -156,19 +231,19 @@ export function SchemaForm({ // number or integer if (fieldSchema.type === "number" || fieldSchema.type === "integer") { return ( - <NumberInput + <SchemaNumberInput key={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)} /> ); } From 6f47eaa1438fe6907ba19a1737e0bea3bf99568b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:23:43 -0400 Subject: [PATCH 59/93] fix: render a tool result's structuredContent in the Tools screen (#1908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool declaring an `outputSchema` returns its real payload in `structuredContent`; the `content[]` blocks usually only summarize it. v1 rendered that payload as its own inspectable JSON section — v2's ToolResultPanel only ever walked `result.content`, so the payload was dropped with no label, section, or hint that it existed. Adds a `StructuredOutputPanel` group: a collapsible "Structured Output" box (bordered like the existing "Resource Links" box) rendering the payload as pretty-printed, syntax-highlighted, copyable JSON, bounded so a large payload scrolls within itself. ToolResultPanel renders it below the content blocks — and also when `content` is empty (where "No results yet" was simply wrong) and below the alert on an error result, so it is never silently dropped. Adds a `list_items` test-server preset (nested payload, the issue's repro shape) and a `structured-output-http.json` showcase config, documented in the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- README.md | 7 ++ .../StructuredOutputPanel.stories.tsx | 90 ++++++++++++++++ .../StructuredOutputPanel.test.tsx | 87 +++++++++++++++ .../StructuredOutputPanel.tsx | 102 ++++++++++++++++++ .../ToolResultPanel.stories.tsx | 18 ++++ .../ToolResultPanel/ToolResultPanel.test.tsx | 72 +++++++++++++ .../ToolResultPanel/ToolResultPanel.tsx | 38 +++++-- .../configs/structured-output-http.json | 15 +++ test-servers/src/preset-registry.ts | 3 + test-servers/src/test-server-fixtures.ts | 42 ++++++++ 10 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx create mode 100644 test-servers/configs/structured-output-http.json diff --git a/README.md b/README.md index 137927642..c691e8e99 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `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) | | `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) | @@ -206,6 +207,12 @@ 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`. + #### 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`. 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..443c78ab6 --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -0,0 +1,90 @@ +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, + }, +}; + +export const Large: Story = { + args: { + structuredContent: large, + }, +}; + +// 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/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/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/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/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. From e132d551e6bcacd69de8982e89385ee529d1d8a3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:26:17 -0400 Subject: [PATCH 60/93] docs: record where a dependency is declared, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the maintenance rules, a change to the dependency layout has to land in the docs with it. Adds the placement rule in three places, at three altitudes: - README (Setup): the rule plus the concrete drift it prevents. - AGENTS.md: a "Dependency placement" section — the MCP SDK packages are root only, the v1 SDK is not ours and must not become a dependency, and anything reached solely through root-owned code with no manifest (`test-servers/src`, `core/`) is a root devDependency aliased to the repo root in `vitest.shared.mts`. - .github/copilot-instructions.md: the distilled, reviewer-citable form, so a diff re-adding one of these to a client manifest is flagged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 6 ++++++ AGENTS.md | 8 ++++++++ README.md | 2 ++ 3 files changed, 16 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd15..ccf73747f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,6 +72,12 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types. - ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories. +## Dependency placement + +- **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. +- **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. +- Dependencies used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. + ## 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. diff --git a/AGENTS.md b/AGENTS.md index a4185baaf..164baff86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,14 @@ 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 rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `<client>/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. + +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. diff --git a/README.md b/README.md index 137927642..8ae07537b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,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 holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. + ## Running during development For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed): From 3173b264f7cb3f407e142f13a56bb2007016e8de Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:34:41 -0400 Subject: [PATCH 61/93] fix(docker): publish on loopback by default; make a mounted state dir writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1964. - README's Docker recipes dropped v1's `127.0.0.1:` prefix, so `-p 6274:6274` published the Inspector on every host interface. The container's `HOST=0.0.0.0` governs the container's interfaces, not the host's, so `DANGEROUSLY_BIND_ALL_INTERFACES` never covered this. Verified against a real container: from a LAN peer, `GET /` served the injected `MCP_INSPECTOR_API_TOKEN`, and that token then unlocked `/api/*` with no `Origin` header (server.ts:241 allows origin-less requests). Restore the prefix on both recipes, on the port-remap examples, and in the web README. - Adding a server failed inside the container the moment you mounted a volume to keep it. Docker seeds a named volume's ownership from the image's directory at the mount point, and creates it `root:root` when that directory is absent — so the non-root `node` user got `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. Create the dir in the image, owned by `node`, and document the volume recipe (plus the bind-mount `--user` caveat) — without a volume the catalog lives in the writable layer and `--rm` throws it away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- Dockerfile | 9 +++++++++ README.md | 18 +++++++++++++++--- clients/web/README.md | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) 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 137927642..d000f5ad9 100644 --- a/README.md +++ b/README.md @@ -319,14 +319,26 @@ 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 ``` -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). +**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. Publish wider only deliberately, and pair it with a known `MCP_INSPECTOR_API_TOKEN`. + +**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`. + +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/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`. From 5a3a3436ea05e9b26d7728266c61515d5500d220 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:35:04 -0400 Subject: [PATCH 62/93] docs: correct the dependency-classification claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2, four findings, all factual errors in the docs added by the previous commit: - `yaml` is in the root `dependencies`, not `devDependencies`, and its only importer is `test-servers/src/load-config.ts` — not `core/` or the root tooling, as the `vitest.shared.mts` comment claimed. - The rule as written told a reader to put anything reached through root-owned code — `core/` included — in `devDependencies`. That is wrong and would break the published package silently: the client builds externalize npm packages, so a published install resolves them from the root manifest, where devDependencies are absent. Placement (which manifest) and classification (which section) are now stated as separate questions, the second following from who consumes the package at runtime. `yaml`'s runtime classification is left alone and called out instead: moving it changes what ships, which is not a docs change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 3 ++- AGENTS.md | 8 +++++++- README.md | 2 +- vitest.shared.mts | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccf73747f..8f049824f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -76,7 +76,8 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **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 used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 164baff86..31d0fbed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,13 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm **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 rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `<client>/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. +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 `<client>/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. 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`. diff --git a/README.md b/README.md index 8ae07537b..1237e5415 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ 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 holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. +**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 diff --git a/vitest.shared.mts b/vitest.shared.mts index f52919cc2..1ebbc96ae 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -83,16 +83,16 @@ export function vitestSharedPaths(clientDir: string) { 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 root-owned code that has no manifest - // of its own — `test-servers/src` imports express, and `core`/the root - // tooling uses yaml — 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. + // 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(repoRoot, "node_modules/express"), From 24a978633736cf1492d7e2fc53d1a8e332343798 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:02:59 -0400 Subject: [PATCH 63/93] fix(web): reset a number field's draft text when the form changes entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. The draft/value re-sync compares parsed numbers, so it cannot see a reset to an *equal* value. `ToolDetailPanel` is not keyed by tool, so its fields are reused across selections: type `-` (draft `"-"`, value `undefined`), switch to a tool with a same-named number field and no default, and the value is `undefined` on both sides — no divergence is detected and the stale `-` stays in the box for the new tool. Add a `resetKey` prop carrying the identity of whatever the form edits, and vary the number field's React key with it so no in-progress text can outlive the entity it was typed into. `ToolDetailPanel` passes the tool name; nested object forms inherit it. The schema object is no substitute — callers rebuild it every render, so its identity is unstable. The regression test drives the switch with `fireEvent` rather than `user.click`: a real click also blurs the input, and Mantine sanitizes an incomplete value on blur, which masked the defect entirely. A paired test asserts a *stable* `resetKey` does not remount, since remounting on every render would wipe the draft and reinstate #1888. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../groups/SchemaForm/SchemaForm.test.tsx | 76 ++++++++++++++++++- .../groups/SchemaForm/SchemaForm.tsx | 31 +++++++- .../ToolDetailPanel/ToolDetailPanel.tsx | 4 + 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index a381add1f..32263e9aa 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2,7 +2,11 @@ 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", () => { @@ -332,6 +336,76 @@ describe("SchemaForm", () => { expect(onChange).toHaveBeenLastCalledWith({ count: 15 }); }); + 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(); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 0e67d854d..efcb32e30 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -98,6 +98,11 @@ interface SchemaNumberInputProps { * 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, @@ -129,6 +134,25 @@ export interface SchemaFormProps { 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 { @@ -153,6 +177,7 @@ export function SchemaForm({ values, onChange, disabled = false, + resetKey, }: SchemaFormProps) { const properties = schema.properties ?? {}; const requiredFields = schema.required ?? []; @@ -232,7 +257,9 @@ export function SchemaForm({ if (fieldSchema.type === "number" || fieldSchema.type === "integer") { return ( <SchemaNumberInput - key={fieldName} + // 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} @@ -318,6 +345,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/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} />} From 5be2b3e87b7779174aa6e813c06f3fc829498388 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:15:28 -0400 Subject: [PATCH 64/93] =?UTF-8?q?docs:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20volume=20upgrade=20path,=20and=20drop=20the=20token?= =?UTF-8?q?=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A named volume created by an image predating the ownership fix keeps its root ownership if it is non-empty, since Docker only applies the image directory's ownership to an *empty* volume. Verified both halves: an empty pre-existing volume repairs itself on the first run of the fixed image, while one holding a file stays root-owned and still EACCESes. Document the distinction and the one-shot `chown` repair (verified to restore writes). - "pair it with a known MCP_INSPECTOR_API_TOKEN" was bad advice for a wider publication: the same paragraph notes `GET /` discloses the token, so a custom one is harvested exactly as a generated one is. Point at a real access-control boundary instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d000f5ad9..a9562079d 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ docker build -t 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. Publish wider only deliberately, and pair it with a known `MCP_INSPECTOR_API_TOKEN`. +**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: @@ -338,6 +338,14 @@ docker run --rm -p 127.0.0.1:6274:6274 \ 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 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` From 6fd03d6de4442a841648836280f74f970ff4d491 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:22:05 -0400 Subject: [PATCH 65/93] fix(web): don't round unrepresentable numbers, and reset app-switch drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2. Three findings, all real. 1. Precision. Mantine's `NumberInput` returns the raw *string* rather than a number once the value reaches `Number.MAX_SAFE_INTEGER` (see its `isValidNumber` guard), precisely because JS cannot hold it. Parsing that turned `90071992547409910` into `90071992547409904` and would have sent the server a number the user never typed. An inspector must not misreport what it transmits, so such input now reports no value — which is also what this field did before #1888, so nothing regresses. Only the integer part overflows, so long decimals stay parsed. 2. `AppDetailPanel` has the same reuse pattern as `ToolDetailPanel`: `AppsScreen.handleSelect` swaps `selectedAppName` + `formValues` in place without remounting, so switching between apps with a same-named number field could retain an equal-valued draft. It now passes `resetKey` too. 3. An exponent was never reachable: `NumberInput` masks input through `NumericFormat`, which rejects `e`. Dropped that claim from the doc comment rather than implying `"1e"` is a case being handled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../AppDetailPanel/AppDetailPanel.test.tsx | 24 ++++++++++++++ .../groups/AppDetailPanel/AppDetailPanel.tsx | 6 ++++ .../groups/SchemaForm/SchemaForm.test.tsx | 28 +++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 31 ++++++++++++++++--- 4 files changed, 84 insertions(+), 5 deletions(-) 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/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 32263e9aa..5b9f064ae 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -336,6 +336,34 @@ describe("SchemaForm", () => { 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 diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index efcb32e30..232b5327a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -53,10 +53,26 @@ function toEnumData( /** * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the - * field. It emits a `number` once the text parses cleanly, and the **raw string** - * while it does not — `""` when cleared, but also the in-progress `"1."`, `"-"`, - * and `"1e"`. Anything that is not a finite number becomes `undefined`, which is - * how an absent optional argument is represented everywhere else in this form. + * 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") { @@ -66,7 +82,12 @@ function toNumericValue(raw: string | number): number | undefined { return undefined; } const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; + 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 { From 8049b4f7c8d3fb6cf2d6e9adf99aa6334c100f8a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:28:01 -0400 Subject: [PATCH 66/93] test: cover the structured-output cap and the list_items showcase end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from the Copilot review on #1975: - The `Large` story asserted nothing, so dropping the section's `mah` (or applying it to the wrong element) would still pass CI. Its play function now measures the real ScrollArea viewport: the visible height stays at the cap and the overflow is scrollable rather than clipped. - Nothing exercised the `list_items` fixture, so a preset-registration typo would leave the documented showcase broken with a green suite. A new integration test loads `structured-output-http.json` through loadConfig/resolveConfig — covering the file, the registry entry, and the fixture — connects, calls the tool, and asserts both the summary block and the nested structuredContent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../StructuredOutputPanel.stories.tsx | 19 +++++++ .../integration/mcp/inspectorClient.test.ts | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx index 443c78ab6..c66760f00 100644 --- a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -59,10 +59,29 @@ export const Flat: Story = { }, }; +// 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. diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219a..1a1645d8b 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, @@ -1214,6 +1217,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; From 29e3ce95b826ac13ef34ef348ad4e3924661f94c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 12:21:14 -0400 Subject: [PATCH 67/93] chore(smoke): run the web smokes against a throwaway catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startProdWebServer` spread `process.env` without setting `MCP_CATALOG_PATH`, so `smoke:web`, `smoke:web:browser`, and `smoke:web:app` all ran against the developer's real `~/.mcp-inspector/mcp.json` — unlike `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. That made the web 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, 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 (App.tsx:2842) so the smoke still passed, but it reported a spurious non-fatal console error that was really just residue from the previous run. Give the helper its own temp catalog dir and clean it up in `stop()` via `removeSafe`, so cleanup can never turn a passing smoke red. Isolation lives in the shared helper rather than per script, since every web smoke needs it. Only the catalog is redirected — other per-user state under `~/.mcp-inspector` stays shared, since isolating it means redirecting HOME wholesale, which would also move the npx and Playwright caches these smokes depend on. Verified: `smoke:web:app` twice in a row is now clean both times (previously the second run reported the 409), the real catalog's mtime is unchanged, and no temp dirs leak. CI never saw this — a fresh HOME per run made every CI run look like a first run. Closes #1977 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 46 ++++++++++++++++++++++++++++----- scripts/smoke-web-app.mjs | 7 ++++- scripts/smoke-web-browser.mjs | 7 ++++- scripts/smoke-web.mjs | 7 ++++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 254410efd..2991d5882 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,9 +14,13 @@ */ 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 } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); @@ -24,20 +30,43 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); * 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 the temp dir + cleanup warnings */ -export function startProdWebServer({ host, port, token }) { +export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; + // The file need not exist — the backend seeds an empty catalog on first run. + const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + const catalogPath = join(catalogDir, "catalog.json"); + const child = spawn(process.execPath, [launcherEntry, "--web"], { env: { ...process.env, 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", }, @@ -119,10 +148,15 @@ export function startProdWebServer({ host, port, token }) { return { baseUrl, + catalogPath, waitForReady, whenChildExits, stop: () => { if (!exited) child.kill("SIGTERM"); + // SIGTERM is not awaited, so the server may still hold the catalog for a + // moment. `removeSafe` warns rather than throws, so the worst case is a + // leaked temp dir the OS reclaims — never a red smoke over cleanup. + removeSafe(catalogDir, { label }); }, }; } diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 73ca5f03b..dce988057 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -104,7 +104,12 @@ let mcpUrl = null; let mcpServer = null; let browser = null; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:app", +}); async function shutdown() { if (browser) { diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 1188865cf..dbc908446 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() { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 143ba925d..2fbcfb440 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -30,7 +30,12 @@ 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) { console.error(`smoke:web FAILED — ${message}`); From 16c96d44c33d876df1bc6badbd426909a299fdae Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:25:09 -0400 Subject: [PATCH 68/93] fix(smoke): await the server's exit before removing its catalog, and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Copilot review comments on #1978. 1. `stop()` removed the catalog dir immediately after a bare `child.kill()`, which only *delivers* SIGTERM. That is precisely the #1801 race `child-cleanup.mjs:11-17` documents, and it says both halves are needed: `stopChild()` closes the race on the normal path, `removeSafe()` makes the residual case harmless. I had used only the second. `smoke-web-app.mjs` already used `stopChild` for its MCP test-server child, so the helper was inconsistent with its own sibling. `stop()` is now async — it awaits `stopChild` and then removes — and all three callers await it (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to `await fail(...)` or execution would run past it instead of exiting). 2. The isolation contract had no test. The smokes exit the process right after teardown, so a regression reintroducing the shared catalog — or dropping cleanup — would leave them all green. Split the two pure pieces out (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign ordering that guard depends on. `startProdWebServer` itself stays untested by design; it spawns a real launcher, which is the smokes' job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 84 ++++++++++++++++++----- scripts/lib/prod-web-server.test.mjs | 99 ++++++++++++++++++++++++++++ scripts/smoke-web-app.mjs | 2 +- scripts/smoke-web-browser.mjs | 2 +- scripts/smoke-web.mjs | 17 +++-- 5 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/prod-web-server.test.mjs diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 2991d5882..6365a1557 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -20,12 +20,64 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { removeSafe } from "./child-cleanup.mjs"; +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", + }; +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. @@ -56,20 +108,10 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; - // The file need not exist — the backend seeds an empty catalog on first run. - const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); - const catalogPath = join(catalogDir, "catalog.json"); + 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, - MCP_CATALOG_PATH: catalogPath, - // Don't pop a browser in CI. - MCP_AUTO_OPEN_ENABLED: "false", - }, + env: buildWebServerEnv({ host, port, token, catalogPath }), stdio: ["ignore", "inherit", "inherit"], }); @@ -151,11 +193,17 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - stop: () => { - if (!exited) child.kill("SIGTERM"); - // SIGTERM is not awaited, so the server may still hold the catalog for a - // moment. `removeSafe` warns rather than throws, so the worst case is a - // leaked temp dir the OS reclaims — never a red smoke over cleanup. + /** + * Terminate the server, then remove its catalog dir. **Await this** — 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. + */ + stop: async () => { + await stopChild(child, { label, what: "prod web server" }); removeSafe(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..bcf65f25d --- /dev/null +++ b/scripts/lib/prod-web-server.test.mjs @@ -0,0 +1,99 @@ +/** + * 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 { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs"; +import { 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); + } +}); + +test("createTempCatalog: its dir is removable by removeSafe", () => { + const { dir, path } = createTempCatalog(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{}"); + assert.equal(removeSafe(dir), true); + 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/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index dce988057..c5f2f8a60 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -120,7 +120,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); if (mcpServer) { const child = mcpServer; mcpServer = null; diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index dbc908446..ade88c547 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -95,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 2fbcfb440..867ae4ffd 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -37,31 +37,34 @@ const server = startProdWebServer({ 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)); } From 4a993fc45a93f74ce1b423d9f1ad355fd83c0a36 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:32:20 -0400 Subject: [PATCH 69/93] docs(smoke): document the throwaway-catalog invariant and fix the label JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two review comments that still applied to HEAD (the third, the stop() race, was already fixed in a8022138). - AGENTS.md still described the prod-web-server helper as shared by only smoke:web and smoke:web:browser, and said nothing about catalog isolation. It is the source of truth for these smoke mechanics, so record the invariant there: the temp catalog per run and why, that only the catalog (not HOME) is redirected, that teardown needs both halves of child-cleanup.mjs and is therefore async for every caller, and where the contract is unit-tested. - The `label` JSDoc claimed the option prefixes the temp dir. It does not — that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's dir is greppable); `label` only prefixes teardown warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 4 +++- scripts/lib/prod-web-server.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 829442c8c..7a864308c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -657,7 +657,9 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **`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` / `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` (`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's pure halves — `createTempCatalog` and `buildWebServerEnv` — are 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. - `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. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 6365a1557..708933eee 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -103,7 +103,8 @@ export function buildWebServerEnv({ * @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 the temp dir + cleanup warnings + * @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, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; From 3d541388db763bd655097ef4b91fc34d40743db0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:51:12 -0400 Subject: [PATCH 70/93] test(smoke): exercise the real teardown path, not just removeSafe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot was right that the previous test proved only that `removeSafe` can delete a directory — it never drove `stop()`, so deleting the cleanup call would have left the unit suite AND all three smokes green. That is exactly the regression these tests exist to catch, and they did not catch it. Extract the teardown into an exported `teardownWebServer({ child, catalogDir, label })`, which `stop()` now delegates to, and drive it against a stand-in child process (a real `node -e setInterval` that stays alive until signalled) rather than a mock. Three cases: - it removes the catalog dir it was given (asserted on the dir itself, not a spy, so the assertion cannot drift from the behavior); - it awaits the child's exit before removing — the #1801 race in one assertion, since losing the await is otherwise invisible; - an already-dead child is not an error, which is the failure path a smoke takes when it calls fail() after the launcher has crashed. Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail (previously: 0). AGENTS.md updated to match. Signed-off-by is now on every commit (#1978 DCO); the requirement itself is undocumented and tracked in #1979. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- scripts/lib/prod-web-server.mjs | 45 ++++++++++++++------ scripts/lib/prod-web-server.test.mjs | 62 +++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a864308c..5f9e44bca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -659,7 +659,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - `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` 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's pure halves — `createTempCatalog` and `buildWebServerEnv` — are 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. + **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. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 708933eee..44986c2c7 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -78,6 +78,36 @@ export function buildWebServerEnv({ }; } +/** + * 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. Testing it + * matters more than it looks: deleting the `removeSafe` call would leave both the + * unit suite and all three smokes green (the smokes exit immediately after + * teardown), which is precisely the regression the tests exist to catch. + * + * @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. @@ -194,18 +224,7 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - /** - * Terminate the server, then remove its catalog dir. **Await this** — 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. - */ - stop: async () => { - await stopChild(child, { label, what: "prod web server" }); - removeSafe(catalogDir, { label }); - }, + /** 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 index bcf65f25d..d3f46671f 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -15,11 +15,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +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 } from "./prod-web-server.mjs"; -import { removeSafe } from "./child-cleanup.mjs"; +import { + buildWebServerEnv, + createTempCatalog, + teardownWebServer, +} from "./prod-web-server.mjs"; +import { hasExited, removeSafe } from "./child-cleanup.mjs"; const BASE = { host: "127.0.0.1", @@ -53,11 +59,55 @@ test("createTempCatalog: does not create the catalog file itself", () => { } }); -test("createTempCatalog: its dir is removable by removeSafe", () => { +/** + * 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(); - mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, "{}"); - assert.equal(removeSafe(dir), true); + assert.ok(existsSync(dir)); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); +}); + +test("teardownWebServer: waits for the child to exit before removing", async () => { + // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a + // teardown that removed synchronously could unlink the dir while the server was + // still writing to it. If teardown resolves with the child still alive, the + // await on stopChild has been lost. + const child = spawnIdleChild(); + const { dir } = createTempCatalog(); + assert.equal(hasExited(child), false, "child should start alive"); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.ok(hasExited(child), "teardown must await the child's exit"); +}); + +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); }); From 678184ed3e94aa0df9509ac9c87015c52273ded7 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:17:56 -0400 Subject: [PATCH 71/93] fix(smoke): pin the teardown ordering, and correct a docblock the test refuted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both round-3 review points, both mine. 1. The docblock claimed deleting `removeSafe` would leave "both the unit suite and all three smokes green" — written before the test existed and never updated, so it contradicted the test added in the same PR. Only the smokes would stay green; the unit tests are precisely what fails on it. 2. `assert.ok(hasExited(child))` after the await proved only that the child was dead by the time teardown returned. It passes just as happily if the catalog were removed *first*, or while an un-awaited `stopChild` was still pending — so it did not lock down the #1801 ordering it claimed to. Sample the directory from the child's own `exit` handler instead, registered before teardown so it runs ahead of stopChild's listener and observes the world at the instant the child dies. `removeSafe` cannot have run by then: it is sequenced after the promise stopChild resolves from that very event. Mutation-checked against both defects the old assertion missed — removing before stopping, and dropping the await. Each now fails the test; neither did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 9 +++++---- scripts/lib/prod-web-server.test.mjs | 24 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 44986c2c7..895d95093 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -89,10 +89,11 @@ export function buildWebServerEnv({ * 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. Testing it - * matters more than it looks: deleting the `removeSafe` call would leave both the - * unit suite and all three smokes green (the smokes exit immediately after - * teardown), which is precisely the regression the tests exist to catch. + * 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 diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs index d3f46671f..0888d887f 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -84,18 +84,32 @@ test("teardownWebServer: removes the catalog dir it was given", async () => { assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); }); -test("teardownWebServer: waits for the child to exit before removing", async () => { - // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a - // teardown that removed synchronously could unlink the dir while the server was - // still writing to it. If teardown resolves with the child still alive, the - // await on stopChild has been lost. +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 () => { From bb599e5057d89e96a46b0d22faf454e821bda9bf Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:28:30 -0400 Subject: [PATCH 72/93] =?UTF-8?q?docs:=20add=20the=20six-month=20Inspector?= =?UTF-8?q?=20roadmap=20(Aug=202026=20=E2=86=92=20Feb=202027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through v1 the Inspector was a follow-along project — the spec moved, we chased it, and whatever planning capacity was left went to keeping up rather than to the tool's own design. v2 meeting the 2026-07-28 spec across all three clients lifts that constraint, but nothing written down said so, and there was no shared list of what the MCP roadmap is likely to demand of us next. Add docs/inspector-roadmap-2026-h2.md covering 2026-08-11 → 2027-02-11 (~26 weekly milestones, v2.2.0 → ~v2.27.0), in two explicitly-budgeted tracks. Track A predicts Inspector features per MCP roadmap theme and WG charter, each tagged build-now / design-now-build-on-signal / watch, so a speculative item gets a liaison rather than code: transports and session lifecycle, Server Cards (SEP-2127), Tasks retry/expiry and the extension→core migration, enterprise audit + ID-JAG, triggers/events, result types, interceptors (SEP-1763), file inputs (SEP-2356), skills (SEP-2640), primitive grouping, and conformance. Track B is the work we choose, headlined by the zoomable protocol/network timeline — lanes, spans rather than points, brush-to-filter, MRTR and task grouping — then session record/replay, a shared diff primitive, a command palette, saved-call collections feeding CI assertions, the argument-editor workstream, and Connection Doctor. The sequencing argument is that several Track B items are force multipliers for Track A: each protocol feature arrives with a rendering problem, and one general timeline plus one general diff is cheaper than a bespoke panel per SEP. So the general surfaces come first and the SEP work renders into them. Two consolidations the survey surfaced, both worth acting on regardless of whether this plan is adopted as written: - #1853, #1856, #1885, #1928, #1919 and #1910 are one defect class, not six bugs — the argument editor is not schema-aware. Treating them separately has already produced one regression from v1.x (#1928). - #962, #1936, #1951, #1944 and #1914 are one missing feature: an ordered diagnostic that reports which connection step failed and what to do. Sourcing is the published roadmap plus the nine WG and six IG charters, cited in full at the end; the doc says so, and flags that Track A should be revised if internal planning carries themes the public page omits. Status is "Draft for WG review" — merging this starts the discussion rather than settling it. Adds one file and touches nothing else. The README guide list and the AGENTS.md docs/ tree entry are deliberately left for a follow-up rather than bundled here, to keep this diff to the document itself. Markdown-only, and outside every format and typecheck glob (SOURCE_EXTENSIONS has no "md"), so no gate is affected. Closes #1980 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CaLPZQzfWDHoPxyL1vbYL Signed-off-by: cliffhall <cliff@futurescale.com> --- docs/inspector-roadmap-2026-h2.md | 570 ++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/inspector-roadmap-2026-h2.md 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) From f060c97e8fd9b5a00673ddd2580ccebb539bbd11 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:47:01 -0400 Subject: [PATCH 73/93] docs: mirror the smoke-isolation invariant into copilot-instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md requires review-relevant changes to land in the Copilot mirror in the same PR, and the catalog-isolation rules are review-enforceable: a new web smoke that spawns its own server, or a teardown that removes a work dir without first awaiting stopChild, is something a reviewer should flag. Mirrored as one line under "Gates and PR hygiene" rather than the AGENTS.md paragraph. The mirror is read on every review and its own rule is "a distillation, not a copy" — the mechanism (why the 409 appeared, why only the catalog and not HOME is redirected, how the contract is tested) belongs in AGENTS.md, while what a reviewer needs is the invariant and the two shapes that violate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 250a8300d..0628741c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,6 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - `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 #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. From f4529df219b8c4327109c97fa04d446ba0b93e81 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:20:14 -0400 Subject: [PATCH 74/93] docs: document the DCO signoff requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCO check fails any PR with an unsigned commit, and the rule appeared nowhere in the repo — not AGENTS.md, CONTRIBUTING.md, README.md, or the Copilot mirror. A hard merge gate that is invisible until it fails, whose remedy is a history rewrite, is exactly what AGENTS.md exists to capture. It blocked #1978. Added to the "When work is complete" list beside the other PR-hygiene gates, weighted toward prevention over recitation: `git config format.signOff true` once per clone beats remembering `-s`, because the failure is only discovered after pushing. The repair path (`git rebase --signoff` + `--force-with-lease`) carries the rebase caveat and the empty-remediation-commit alternative for when you are not the sole author, plus a note that the signoff is a DCO assertion in the author's own name and is never applied on someone else's behalf. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for PR-hygiene rules — two lines next to `Closes #N` and the version label, since the mirror is read on every review and is a distillation, not a copy. Not added to CONTRIBUTING.md: external contributors file issues rather than PRs, so DCO never reaches them, and documenting it there would muddy that. Closes #1979 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + AGENTS.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c4..f9d995d34 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,6 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **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 #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`. - 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/AGENTS.md b/AGENTS.md index 5f9e44bca..6a19dfb89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,10 @@ All work should be driven by items on the project board. - **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 every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. + - **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). If others are on the branch, add an empty remediation commit instead. + - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - 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.) From b3242bc04c0748177bb6ea243f65be451d42fecc Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:29:54 -0400 Subject: [PATCH 75/93] fix(docs): format.signOff does not sign commits, and remediation is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both review points were factually right and my original text was wrong. 1. `git config format.signOff true` — which I had recommended as the *better* option — only defaults the -s flag for `git format-patch`. `git commit` never reads it, and there is no `commit.signoff` equivalent. Verified all three in a throwaway repo: format.signOff → no trailer; commit.signoff → no effect; a prepare-commit-msg hook → works. Shipping that advice would have been worse than saying nothing, since it looks like a fix and silently changes nothing. Now recommends `git commit -s`, gives the verified hook for automation, and names format.signOff explicitly as a trap — the name invites exactly the mistake I made, so it is worth warning about rather than omitting. 2. The DCO app's empty remediation-commit flow needs `allowRemediationCommits.individual`. This repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. The escape hatch I offered does not exist here; rewriting is the only repair, so the text now says to coordinate before rewriting shared history instead. Mirror updated with both corrections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f9d995d34..ffc4c38f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **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 #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~<n> --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/AGENTS.md b/AGENTS.md index 6a19dfb89..afbb05c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,20 @@ All work should be driven by items on the project board. - 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 every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. - - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. - - **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). If others are on the branch, add an empty remediation commit instead. + - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: + + ```sh + cat > .git/hooks/prepare-commit-msg <<'HOOK' + #!/bin/sh + grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ + --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + HOOK + chmod +x .git/hooks/prepare-commit-msg + ``` + + ⚠️ **`git config format.signOff true` does NOT do this** — 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, so it is a trap worth naming rather than omitting. + + - **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). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - 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`. From 6899e0ae1e821e0a6d7270dfbb02b29fbfb463f9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:46:32 -0400 Subject: [PATCH 76/93] fix(docs): make the signoff hook identity-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook skipped insertion on any `Signed-off-by:` line, which is wrong for a message reused from another commit — cherry-pick, `git commit -c` — since that message already carries someone else's trailer. The DCO check requires one matching the *author*, so the guard would be satisfied while the commit still failed the gate: the exact silent-no-op failure mode this bullet warns about. Match this identity's exact trailer instead. Verified: a plain commit signs once; a message already carrying another author's signoff now gets the real author's added alongside it; `git commit -s` is not double-signed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afbb05c43..ae7cb54db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,8 +213,12 @@ All work should be driven by items on the project board. ```sh cat > .git/hooks/prepare-commit-msg <<'HOOK' #!/bin/sh - grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ - --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + # Match THIS identity's exact trailer, not any Signed-off-by: a message + # reused from another commit (cherry-pick, `git commit -c`) already carries + # someone else's, and the DCO check requires one matching the author — so a + # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. + SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" + grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" HOOK chmod +x .git/hooks/prepare-commit-msg ``` From 3c93c0502104159a8b50dfd16bf6ad696f776d75 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:59:16 -0400 Subject: [PATCH 77/93] fix(docs): the DCO app does have a write-access override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims were wrong, both verified against the app's own documentation (dcoapp/app): "The DCO also creates an override button for users with write access to the repository to create a successful check." So "there is no per-PR override" and "there is no non-rewrite escape hatch" were both false, and a maintainer following them would conclude that rewriting history was the only way to unblock a PR. Documented, but deliberately not as a repair. The override only silences the check; it does not make the author certify anything, and the certification is the whole point of a DCO signoff. Using it on your own unsigned commits asserts nothing while looking like compliance — so the text says to treat it as unavailable and sign the commits instead. The same source confirms two things the doc already claimed: remediation commits are disabled by default, and the trailer must match the commit author's email (which is what the identity-aware hook in 6899e0ae exists for). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae7cb54db..fc4950827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ All work should be driven by items on the project board. - **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 every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Sign off every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. 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, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: ```sh @@ -225,7 +225,7 @@ All work should be driven by items on the project board. ⚠️ **`git config format.signOff true` does NOT do this** — 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, so it is a trap worth naming rather than omitting. - - **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). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. + - **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** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - 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`. From eb5bba62ede2c27d9082a833bcf23e34d4cda2ee Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:00:01 -0400 Subject: [PATCH 78/93] fix(tui): bundle React-rendering deps so a consumer install can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ink-form` and `ink-scroll-view` were externalized by the TUI bundler and both declare a loose `react` peer (">=18"). Installed under a project that has its own React 18, npm hoists them to that project's root — where the peer is satisfied — while the Inspector's React 19 nests underneath. The bundle then renders through React 19 while those two call hooks on React 18, whose dispatcher is null, so opening a tool test form (or any scroll view) dies with "Cannot read properties of null (reading 'useState')". Inline them instead: their `import "react"` is emitted into the bundle and so resolves from the build directory exactly like the bundle's own, with npm's placement no longer part of the decision. This also pins their transitive deps to what this repo resolved — notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency, so consumers were getting the React-18-era v5. `ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails ESM interop with `Dynamic require of "assert"`) and doesn't need to be — its ">=19" peer keeps npm from hoisting it next to an unusable React. Both packages are dropped from the root `dependencies`, since the tarball now ships their code rather than having consumers install them. `clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the failure is invisible in this repo and in every smoke (a dev install has one React) and only appears once the package is installed under another project, so the invariant is asserted against the config instead — every dependency declaring a `react` peer must be bundled unless exempted. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 1 + AGENTS.md | 2 + README.md | 1 + clients/tui/README.md | 32 ++++++ clients/tui/__tests__/tsupConfig.test.ts | 130 +++++++++++++++++++++++ clients/tui/tsup.config.ts | 22 +++- package-lock.json | 111 ------------------- package.json | 2 - 8 files changed, 185 insertions(+), 116 deletions(-) create mode 100644 clients/tui/__tests__/tsupConfig.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c4..24367041a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,6 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- **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; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). - **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 diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bca..8cbfdcf5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,8 @@ The same **placement** rule covers anything reached only through **root-owned co - 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 package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). + 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 diff --git a/README.md b/README.md index 21b91d046..7f9524943 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ 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. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root 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 React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards 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 diff --git a/clients/tui/README.md b/clients/tui/README.md index 7a784a975..bcf102c36 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -106,3 +106,35 @@ 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. + +### 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 next to a React satisfying its own peer range. +`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that +installs the Inspector alongside its own React 18 satisfies them: they hoist to +that project's root while the Inspector's React 19 nests underneath. The bundle +then renders through React 19 while those two call hooks on React 18, whose +dispatcher is null — so opening a tool test form (or any scroll view) dies with +`TypeError: Cannot read properties of null (reading 'useState')`. + +Bundling them removes npm from the decision: their `import "react"` is emitted +into `build/index.js`, so it resolves from the build directory exactly like the +bundle's own. It also pins their transitive deps to what *this* install +resolved — notably `ink-select-input@6` via the `overrides` entry, since npm +ignores a dependency's `overrides` and a consumer install would otherwise pull +the React-18-era v5 that `ink-form` asks for. + +`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` +dependency fails ESM interop with `Dynamic require of "assert" is not +supported`), and it doesn't need to be — its `react` peer is `">=19"`, which +keeps npm from hoisting it next to a React the Inspector couldn't also use. + +`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a +`react` peer must be in `noExternal` unless it is listed as external by design. +Add a React-rendering dependency, and that test tells you to bundle it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts new file mode 100644 index 000000000..b66cf13b2 --- /dev/null +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -0,0 +1,130 @@ +/** + * 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 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"); +} + +/** + * The one React-rendering dependency deliberately left external. + * + * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM + * interop ("Dynamic require of \"assert\" is not supported") — and does not + * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a + * React the Inspector itself could not use. + */ +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, + ); +} + +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 can be bundled", () => { + 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 react itself external, as the single shared instance", () => { + expect(external).toContain("react"); + expect(noExternal).not.toContain("react"); + }); + + it("documents each React-rendering package left external", () => { + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + } + }); + + 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/tsup.config.ts b/clients/tui/tsup.config.ts index 92a637b37..1a90e87ff 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -15,12 +15,28 @@ 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 them is what guarantees that: + // an inlined package's `import "react"` is emitted into build/index.js and so + // resolves from *this* directory, exactly like the bundle's own — no consumer + // install layout can point it somewhere else (#1952). Left external, npm is + // free to hoist them next to a *different* React: `ink-form` and + // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer + // project holding React 18 satisfies it and gets them hoisted to its own root + // while the Inspector's React 19 nests under it — 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. `__tests__/tsupConfig.test.ts` guards this list. + noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ + // `react` is deliberately external — it is the single instance everything + // above resolves to, from this build directory. "react", + // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` + // dependency fails ESM interop with "Dynamic require of \"assert\""), and + // it does not need to be — its `react` peer is ">=19", which keeps npm + // from hoisting it next to a React the Inspector could not also use. "ink", - "ink-form", - "ink-scroll-view", "open", "commander", "pino", diff --git a/package-lock.json b/package-lock.json index 61e952899..972b65f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,6 @@ "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", @@ -2181,21 +2179,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", @@ -2552,76 +2535,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", @@ -2732,18 +2645,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "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" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4080,18 +3981,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", diff --git a/package.json b/package.json index 05155e998..c09afb9d3 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,6 @@ "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", From bd6c1072bf331f402d192b30824e19e9bf193119 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:09:01 -0400 Subject: [PATCH 79/93] =?UTF-8?q?fix(docs):=20drop=20the=20signoff=20hook?= =?UTF-8?q?=20=E2=80=94=20it=20cannot=20know=20the=20commit=20author?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 was right that the hook signs with the configured identity while the DCO check requires a trailer matching the commit *author*, and that the two diverge on cherry-pick and `git commit -c`. I tried the suggested author-aware guard and it does not work: inside prepare-commit-msg, `git var GIT_AUTHOR_IDENT` returns the config identity, not the preserved author. Verified end to end — cherry-picking Bob's commit as Alice yields author=Bob, committer=Alice, `Signed-off-by: Alice`. That commit both still fails the gate and certifies work Alice did not write, which is the one thing the last bullet of this section forbids. Three review rounds have now found three holes in this hook, each fix opening the next. That is a wrong mechanism rather than an unfinished one, so the hook is out. A subtly-broken hook in the file that governs how this repo is worked on is worse than no hook. The bullet now recommends `git commit -s` and names BOTH false automations — `format.signOff` and the hook — recording the cherry-pick finding so the next person doesn't rediscover it, and pointing anyone who wants automation at a layer that actually knows the author. Mirror updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 22 ++++++---------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ffc4c38f4..5e7edd683 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **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 #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~<n> --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/AGENTS.md b/AGENTS.md index fc4950827..ac9fb4994 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,22 +208,12 @@ All work should be driven by items on the project board. - 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 every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. 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, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: - - ```sh - cat > .git/hooks/prepare-commit-msg <<'HOOK' - #!/bin/sh - # Match THIS identity's exact trailer, not any Signed-off-by: a message - # reused from another commit (cherry-pick, `git commit -c`) already carries - # someone else's, and the DCO check requires one matching the author — so a - # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. - SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" - grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" - HOOK - chmod +x .git/hooks/prepare-commit-msg - ``` - - ⚠️ **`git config format.signOff true` does NOT do this** — 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, so it is a trap worth naming rather than omitting. + - **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. + - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. + + If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. - **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** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. From 3c1bbbcb5fda7e4eb393f7323f1b347726ccc440 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:15:35 -0400 Subject: [PATCH 80/93] fix(tui): correct ink-form's misspelled incomplete-form hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint under a form with unfilled required fields reads "you have not competed yet". The string is upstream's, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override it, and `ink-form` was last published in 2024 — but it renders in the Inspector's tool, prompt, and resource test forms, so it is ours to fix in practice. Bundling `ink-form` (previous commit) is what makes it patchable: an esbuild `onLoad` hook rewrites the label as the file enters the bundle. The hook throws when the string is absent rather than passing the source through, so an `ink-form` upgrade cannot silently retire the patch or leave it aimed at a string that no longer exists — the tests drive it against the real dependency source for the same reason. Reported upstream as lukasbach/ink-form#14. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 16 +++++++ clients/tui/__tests__/tsupConfig.test.ts | 39 ++++++++++++++++- clients/tui/tsup.config.ts | 54 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index bcf102c36..58e8be40d 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -138,3 +138,19 @@ keeps npm from hoisting it next to a React the Inspector couldn't also use. `__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a `react` peer must be in `noExternal` unless it is listed as external by design. 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 index b66cf13b2..1ad3490c6 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -25,7 +25,10 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; -import tsupConfig from "../tsup.config.js"; +import tsupConfig, { + INK_FORM_INCOMPLETE_HINT, + fixInkFormIncompleteHint, +} from "../tsup.config.js"; const clientDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -120,6 +123,40 @@ describe("tui tsup config", () => { } }); + 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 submitButton = readFileSync( + path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", + ), + "utf8", + ); + + const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); + expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + 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. diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 1a90e87ff..3a4c66f74 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,10 +1,63 @@ import { defineConfig } from "tsup"; +import type { Plugin } from "esbuild"; +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, + ); +} + +const inkFormLabelPatch: Plugin = { + name: "ink-form-label-patch", + setup(build) { + build.onLoad( + { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + async ({ path: file }) => ({ + contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), + loader: "js", + }), + ); + }, +}; + export default defineConfig({ entry: ["index.ts"], format: ["esm"], @@ -44,6 +97,7 @@ export default defineConfig({ "@modelcontextprotocol/core", "@napi-rs/keyring", ], + esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { options.alias = { "@inspector/core": path.join(repoRoot, "core"), From c158ad809fb9869daabffa39df0227f4c416874e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:22:22 -0400 Subject: [PATCH 81/93] fix(docs): correct the DCO rules against the validator source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time rather than the README, which is where the earlier errors came from: const authors = [commit.author.name, commit.committer.name] const emails = [commit.author.email, commit.committer.email] if (isMerge || ...) continue // parents.length > 1 else if (author && author.type === 'Bot') continue So, corrected: - Scope. Merge commits and bot-authored commits are skipped, not gated. "Every commit" overstated it and could prompt a needless history rewrite. - Matching. The trailer may match the author OR the committer, not the author alone. A cherry-picker's own signoff is accepted. - Certification. The DCO expressly covers submitting work created by others that you have the right to submit, so signing off a cherry-pick is legitimate. My "never sign off someone else's commit" would have told a maintainer not to do a correct thing. The real prohibition is narrower: never fabricate a trailer in another person's name. This also retracts the mechanical argument in bd6c1072 for dropping the hook — it does satisfy the check, since you are the committer. The recommendation against it stands on honest grounds now: the trailer is a certification, a hook makes it for every commit automatically including work merely applied on someone else's behalf, and it provably cannot tell the difference. A judgment about deliberateness, not a claim that it breaks. Mirror corrected to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5e7edd683..2d2297648 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **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 #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **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~<n> --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/AGENTS.md b/AGENTS.md index ac9fb4994..506ea43fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,16 +207,14 @@ All work should be driven by items on the project board. - **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 every commit — 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 **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. 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. + - **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. - - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. - - If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. + - ⚠️ **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** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. + - 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.) From 39575ff060bcc7f4a7478a19d1d58ec568c39e2c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:29:13 -0400 Subject: [PATCH 82/93] test(tui): drive the ink-form patch through the plugin, and drop the esbuild type import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983. The patch's tests called the string helper directly, so nothing exercised the esbuild `onLoad` filter that decides whether the helper runs at all. A filter that stopped matching would leave the build green, the tests green, and the typo shipped — the silent no-op the throwing helper exists to prevent, reintroduced one level up. The plugin is now driven the way esbuild drives it (register, then invoke) against the real `ink-form` module path, and a second test pins the filter's scope: it matches that path and a Windows-separator one, and rejects `Form.js`, `ink-select-input`'s own `SubmitButton.js`, and a first-party `src/SubmitButton.jsx`. The plugin type came from `import type { Plugin } from "esbuild"`, but `esbuild` is tsup's transitive dependency, not one this client declares — it typechecked only while npm happened to hoist it, and would break on a lock change that nested it. Derive it from tsup's own `Options["esbuildPlugins"]` instead of adding a dependency for a type. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/__tests__/tsupConfig.test.ts | 75 ++++++++++++++++++++---- clients/tui/tsup.config.ts | 21 +++++-- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 1ad3490c6..0cff33ca4 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -27,7 +27,9 @@ 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( @@ -86,6 +88,15 @@ function reactRenderingDependencies(): string[] { ); } +/** 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); @@ -127,22 +138,64 @@ describe("tui tsup config", () => { // 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 submitButton = readFileSync( - path.join( - clientDir, - "node_modules", - "ink-form", - "lib", - "SubmitButton.js", - ), - "utf8", + const patched = fixInkFormIncompleteHint( + readFileSync(submitButtonPath, "utf8"), + submitButtonPath, ); - - const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); 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"), diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 3a4c66f74..b75dd86e9 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,5 +1,4 @@ -import { defineConfig } from "tsup"; -import type { Plugin } from "esbuild"; +import { defineConfig, type Options } from "tsup"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,11 +44,25 @@ export function fixInkFormIncompleteHint(source: string, file: string): string { ); } -const inkFormLabelPatch: Plugin = { +/** + * 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[\\/]lib[\\/]SubmitButton\.js$/ }, + { filter: INK_FORM_SUBMIT_BUTTON }, async ({ path: file }) => ({ contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), loader: "js", From 9fd5bc177cb9e40e651756c533fed14526043bc6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:45:09 -0400 Subject: [PATCH 83/93] chore: drop the orphaned root override, and correct the TUI coverage note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983 (Copilot's suppressed comments). Removing `ink-form` from the root dependencies left `overrides.ink-select-input` with nothing to override: no root dependency reaches that package any more, and the regenerated lock carries no entry for it. The override that matters lives in `clients/tui/package.json`, where the build resolves `ink-form` — and it is what puts `ink-select-input@6` (rather than the React-18-era v5) into the bundle. The README paragraph above the new bundling section still described the Ink components and `App.tsx` as an interim coverage exclusion. #1501 lifted that: `vitest.config.ts` now gates all of `src/**`, excluding only the pure `src/tui-servers.ts` re-export. Corrected to match the config it describes. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 14 ++++++++++---- package.json | 3 --- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index 58e8be40d..0fb574079 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -102,10 +102,16 @@ 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) diff --git a/package.json b/package.json index c09afb9d3..6484a82f8 100644 --- a/package.json +++ b/package.json @@ -99,9 +99,6 @@ "yaml": "^2.9.0", "zod": "^4.4.3" }, - "overrides": { - "ink-select-input": "^6.2.0" - }, "engines": { "node": ">=22.19.0" }, From 1e0f94bb31a9034159f4b1b5895b01f43ca5869f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:16:05 -0400 Subject: [PATCH 84/93] fix(test): clear leaked timers so one cannot fail an unrelated CI run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window.setTimeout that outlives its test file fires after happy-dom disposes that file's window; React's dispatchSetState then throws an uncaught ReferenceError: window is not defined. Vitest attributes it to whichever file was running, so a single leak fails the ENTIRE run with every test passing — landing on an innocent file under a step named "Enforce per-file coverage gate." Seen twice in CI, both on docs-only branches. Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount (useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the unmount lands, cancelAnimationFrame is a no-op and that callback then schedules a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is why it only ever appeared in CI. Track every timer and clear whatever is still outstanding after cleanup(). The ordering is load-bearing: legitimate unmount cleanups get their turn first, so only true leaks are dropped. Fake timers pass through untracked, which is correct — a fake timer cannot outlive the environment. Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that env="test" prevents this. It does not: env is read only by Transition.mjs at its render branch, while useTransition runs before that check (hooks cannot be conditional) and still schedules real timers. Measured — opening a <Modal> through renderWithMantine schedules three 200ms timers. Believing that guarantee is why this went unexamined. The regression test is mutation-verified: with the net disabled it fails, with it restored it passes. It deliberately does not assert that Mantine schedules no timers — it does, and that is normal; the contract is that none survive. Closes #1984 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- clients/web/src/test/leakedTimers.test.tsx | 72 ++++++++++++++++++++ clients/web/src/test/setup.ts | 79 ++++++++++++++++++++-- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 clients/web/src/test/leakedTimers.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bca..8584c2315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **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. - **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** (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. ### Responding to Code Reviews diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx new file mode 100644 index 000000000..ea51b4be1 --- /dev/null +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -0,0 +1,72 @@ +/** + * 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"; + +/** Ids the net is tracking, read back through the wrapper it installed. */ +function scheduleTracked(ms: number): number { + return window.setTimeout(() => {}, ms) as unknown as number; +} + +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 stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + const id = scheduleTracked(1000); + expect(() => window.clearTimeout(id)).not.toThrow(); + // Clearing twice must stay a no-op — the net also clears at teardown. + expect(() => window.clearTimeout(id)).not.toThrow(); + }); + + 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, +}; diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index d818a2cec..c84a313fc 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,73 @@ 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 clear whatever is +// still outstanding once the test's own teardown has had its turn. Ordering is +// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount +// cleanups clear their own timers and only true leaks are left. +// +// 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. +const pendingTimers = new Set<number>(); +const realSetTimeout = window.setTimeout.bind(window); +const realClearTimeout = window.clearTimeout.bind(window); + +window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] +): number => { + const id: number = realSetTimeout( + (...cbArgs: unknown[]) => { + pendingTimers.delete(id); + if (typeof handler === "function") { + handler(...cbArgs); + } + }, + timeout, + ...args, + ); + pendingTimers.add(id); + return id; + // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), + // while the DOM lib types it as `number`. The implementation above honors the + // DOM shape the app codes against; this cast bridges the two declarations, + // which TS cannot relate structurally. +}) as unknown as typeof window.setTimeout; + +window.clearTimeout = ((id?: number): void => { + if (typeof id === "number") { + pendingTimers.delete(id); + } + realClearTimeout(id); + // Same DOM-vs-Node overload mismatch as `setTimeout` above. +}) as unknown as typeof window.clearTimeout; + afterEach(() => { cleanup(); window.localStorage.clear(); + // After cleanup(), anything still pending is a leak — drop it so it cannot + // fire past this file's environment teardown. + for (const id of pendingTimers) { + realClearTimeout(id); + } + pendingTimers.clear(); }); From 94a227b9c12451ee7810f1177e4992ac4713742c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:21:43 -0400 Subject: [PATCH 85/93] fix(tui): widen the root react range so an external ink can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #1983. Copilot was right, and about a hazard this repo had already proven and then documented away: the claim that ink's ">=19" peer "keeps npm from hoisting it next to a React the Inspector couldn't also use" is false. A consumer pinning React 19.0 satisfies ">=19" while `^19.2.4` nests underneath, so ink renders through their React and the bundle through ours — the #1952 crash, one level up, breaking TUI startup rather than just its forms. Bundling ink closes it (verified: with a `createRequire` banner for the inlined CJS, since react-reconciler calls `require("react")` and signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler and yoga-layout come along. The root `react` range closes it for free instead: `^19.0.0` is open to the whole major, so npm can satisfy our React and a consumer's pinned React 19 with one copy, and an external ink resolves the same copy the bundle does. Verified against both consumer trees — the reporter's (React 18 + ink 5) and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`. That makes the range load-bearing while looking like an ordinary version bump, so the tests pin it: the root range must equal ink's own declared peer floor. They also assert each exempt package is both external and a root dependency — external means consumers install it, the mirror image of the inlined ones, which must not be root-declared. The docs in all four places now say ink is exempt on cost and never on a peer range, and name that claim as one we got wrong. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 2 +- AGENTS.md | 4 +- README.md | 2 +- clients/tui/README.md | 68 +++++++++++++++++------- clients/tui/__tests__/tsupConfig.test.ts | 62 +++++++++++++++++---- clients/tui/tsup.config.ts | 43 +++++++++------ package-lock.json | 29 +++++----- package.json | 2 +- 8 files changed, 150 insertions(+), 62 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24367041a..48820a000 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,7 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. -- **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; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). +- **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 diff --git a/AGENTS.md b/AGENTS.md index 8cbfdcf5f..0211f8c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,9 @@ The same **placement** rule covers anything reached only through **root-owned co - 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 package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). +**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`. diff --git a/README.md b/README.md index 7f9524943..55459ad07 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ 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. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root 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 React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). +- **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 diff --git a/clients/tui/README.md b/clients/tui/README.md index 0fb574079..1ae2fb514 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -121,28 +121,60 @@ 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 next to a React satisfying its own peer range. -`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that -installs the Inspector alongside its own React 18 satisfies them: they hoist to -that project's root while the Inspector's React 19 nests underneath. The bundle -then renders through React 19 while those two call hooks on React 18, whose -dispatcher is null — so opening a tool test form (or any scroll view) dies with +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')`. -Bundling them removes npm from the decision: their `import "react"` is emitted -into `build/index.js`, so it resolves from the build directory exactly like the -bundle's own. It also pins their transitive deps to what *this* install -resolved — notably `ink-select-input@6` via the `overrides` entry, since npm -ignores a dependency's `overrides` and a consumer install would otherwise pull -the React-18-era v5 that `ink-form` asks for. +`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 +``` -`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` -dependency fails ESM interop with `Dynamic require of "assert" is not -supported`), and it doesn't need to be — its `react` peer is `">=19"`, which -keeps npm from hoisting it next to a React the Inspector couldn't also use. +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 this: every dependency declaring a -`react` peer must be in `noExternal` unless it is listed as external by design. +`__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 diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 0cff33ca4..a9bfc35f3 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -58,12 +58,18 @@ function stringEntries(list: (string | RegExp)[] | undefined): string[] { } /** - * The one React-rendering dependency deliberately left external. + * React-rendering dependencies left external by an explicit trade-off. * - * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM - * interop ("Dynamic require of \"assert\" is not supported") — and does not - * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a - * React the Inspector itself could not use. + * `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"]); @@ -108,7 +114,7 @@ describe("tui tsup config", () => { expect(reactRenderingDependencies().length).toBeGreaterThan(0); }); - it("bundles every React-rendering dependency that can be bundled", () => { + it("bundles every React-rendering dependency that is not exempt", () => { const shouldInline = reactRenderingDependencies().filter( (name) => !EXTERNAL_BY_DESIGN.has(name), ); @@ -123,17 +129,53 @@ describe("tui tsup config", () => { } }); - it("keeps react itself external, as the single shared instance", () => { - expect(external).toContain("react"); - expect(noExternal).not.toContain("react"); + 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("documents each React-rendering package left external", () => { + 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 diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index b75dd86e9..2c0197a9a 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -81,27 +81,38 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - // Every package here renders React components, so it MUST share the one - // React instance the bundle imports. Bundling them is what guarantees that: - // an inlined package's `import "react"` is emitted into build/index.js and so - // resolves from *this* directory, exactly like the bundle's own — no consumer - // install layout can point it somewhere else (#1952). Left external, npm is - // free to hoist them next to a *different* React: `ink-form` and - // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer - // project holding React 18 satisfies it and gets them hoisted to its own root - // while the Inspector's React 19 nests under it — two React copies, and the + // 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. `__tests__/tsupConfig.test.ts` guards this list. + // 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 — it is the single instance everything - // above resolves to, from this build directory. + // `react` is deliberately external — the single instance every inlined + // package above resolves to, from this build directory. "react", - // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` - // dependency fails ESM interop with "Dynamic require of \"assert\""), and - // it does not need to be — its `react` peer is ">=19", which keeps npm - // from hoisting it next to a React the Inspector could not also use. + // `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", "open", "commander", diff --git a/package-lock.json b/package-lock.json index 972b65f24..d574fdd98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "ink": "^6.0.0", "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", @@ -1819,13 +1819,14 @@ } }, "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": { @@ -3887,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", @@ -4024,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" @@ -4291,9 +4292,9 @@ "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" diff --git a/package.json b/package.json index 6484a82f8..c52f04c1b 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "ink": "^6.0.0", "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", From 24d89d7d0b7be942c039a758483c13fea66d443e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:28:34 -0400 Subject: [PATCH 86/93] fix(test): wait for the dead transport instead of sleeping 300ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectDeadSession spawned a real node subprocess whose only job is to die, then slept a flat 300ms for the death to propagate. That budget has 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 box, not under CI contention or a machine running several agents. When the budget was missed the session was still live, so isTransportDead() was false and /api/mcp/send fell through to `await responseWait` for a tools/list reply the dead child can never send. Nothing bounds that wait, so the test hung to the project's full 30s ceiling. The timeout IS the symptom of losing the race, which is why it never presented as a clean assertion failure. Poll the observable condition instead. Two design points that are load-bearing: - The probe is a NOTIFICATION (method, no id). requestIdForSendWait returns undefined for it, so the route never awaits a response and cannot hang. Probing with a request would reproduce the exact bug being fixed. - Not /api/mcp/events, which looks like the natural "transport died" signal: opening that stream on a dead transport calls sessions.delete(sessionId), so the send under test would then answer 404 instead of transport_error. Both terminal branches of the route answer transport_error — the isTransportDead() short-circuit and the catch around a send() that threw on the dead pipe — and either proves the subsequent real send cannot reach the hanging path, so both are valid stop conditions. Verified by reproducing the bug deterministically: with the wait reduced to 0 (the budget always missed) the test times out at 30s; with the poll it passes 10/10. Fixing the helper rather than the test also covers its second call site, the auth-state test, which sits on the same race and had simply not lost it yet. connect-crash.test.ts:147 is deliberately left alone. #1985 proposed giving it the same treatment, but readSseEvents stops at transport_error, which arrives whenever the child dies — so its 200ms sleep cannot hang, and changing it would add risk for no gain. Closes #1985 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../mcp/remote/remote-auth-branches.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) 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..27b5bc6d5 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,63 @@ 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 a **notification** — a `method` with no `id` — which matters: + * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never + * awaits a response and cannot hang. Probing with a *request* would reproduce + * the very failure this guards against, since a request sent before the + * transport is marked dead waits forever for a reply the dead child will never + * send. + * + * Either terminal branch of the route answers `kind: "transport_error"` — the + * `isTransportDead()` short-circuit, and the `catch` around a `send()` that + * threw on the dead pipe. Both are fine as a stop condition: each one proves the + * subsequent real send also cannot reach the hanging path. + * + * 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/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + message: { jsonrpc: "2.0", method: "notifications/initialized" }, + }), + }); + 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 +142,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; } From c4e8ed992bccc82fd1ebc9d988a4a3d84cb4b047 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:58:44 -0400 Subject: [PATCH 87/93] fix(test): cancel queued frames before sweeping timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the net still had the exact hole it was written to close. The afterEach is synchronous, so a queued rAF callback cannot run until it returns — at which point it registers a fresh setTimeout AFTER the sweep has already drained. On a file's last test that timer survives environment teardown. That is the rAF race described in the previous commit message, left open by the fix for it. Track animation frames too, and cancel them BEFORE sweeping timers. Order is the fix: JS is single-threaded and nothing between the two loops yields, so no frame callback can run in between. Mutation-checked — without the cancel pass the new ordering test fails (1 failed | 6 passed); with it, 7 pass. Also from review: - renderWithMantine.tsx still documented the disproven env="test" behavior. It is the file contributors are told to use, so leaving it would have preserved the wrong belief exactly where it does most damage. That was the third copy of this claim in the repo; all three now corrected. - Dropped an unjustified `as unknown as` in the test helper, which broke the repo's own double-cast rule. Inference keeps the handle type clearTimeout accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/test/leakedTimers.test.tsx | 31 ++++++++++++-- clients/web/src/test/renderWithMantine.tsx | 21 ++++++--- clients/web/src/test/setup.ts | 50 +++++++++++++++++++--- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index ea51b4be1..209efa259 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -17,9 +17,10 @@ import { describe, it, expect, vi } from "vitest"; import { Modal } from "@mantine/core"; import { renderWithMantine } from "./renderWithMantine"; -/** Ids the net is tracking, read back through the wrapper it installed. */ -function scheduleTracked(ms: number): number { - return window.setTimeout(() => {}, ms) as unknown as number; +/** 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", () => { @@ -52,6 +53,27 @@ describe("leaked-timer safety net", () => { expect(() => window.clearTimeout(id)).not.toThrow(); }); + 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 @@ -70,3 +92,6 @@ describe("leaked-timer safety net", () => { 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 c84a313fc..a564fa5d5 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -113,17 +113,23 @@ Object.defineProperty(window, "matchMedia", { // `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 clear whatever is -// still outstanding once the test's own teardown has had its turn. Ordering is -// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount -// cleanups clear their own timers and only true leaks are left. +// 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. const pendingTimers = new Set<number>(); +const pendingFrames = new Set<number>(); const realSetTimeout = window.setTimeout.bind(window); const realClearTimeout = window.clearTimeout.bind(window); +const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); +const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); window.setTimeout = (( handler: TimerHandler, @@ -156,11 +162,43 @@ window.clearTimeout = ((id?: number): void => { // Same DOM-vs-Node overload mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; +window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { + const handle: number = realRequestAnimationFrame((time: number) => { + pendingFrames.delete(handle); + callback(time); + }); + pendingFrames.add(handle); + return handle; + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.requestAnimationFrame; + +window.cancelAnimationFrame = ((handle: number): void => { + pendingFrames.delete(handle); + realCancelAnimationFrame(handle); + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.cancelAnimationFrame; + afterEach(() => { cleanup(); window.localStorage.clear(); - // After cleanup(), anything still pending is a leak — drop it so it cannot - // fire past this file's environment teardown. + + // 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) { + realCancelAnimationFrame(handle); + } + pendingFrames.clear(); + + // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { realClearTimeout(id); } From b70aed691418a7e2e98c897cba6ab44ad422f648 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:10:03 -0400 Subject: [PATCH 88/93] test(fix): probe a single-source route when waiting for the dead transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1987: `waitForDeadTransport` polled `/api/mcp/send`, which answers `transport_error` from two branches — the `isTransportDead()` short-circuit and the `catch` around a rejected `send()`. Only the first implies the transport is marked dead, so the stop condition could not tell them apart from the response alone. That was safe in practice: `StdioClientTransport.send` rejects only when `_process` is undefined, and the transport clears `_process` and calls `onclose` in one synchronous block, so the `catch` branch is not observable before `markTransportDead()` has run (confirmed empirically — 0/25 probes returned early). But the safety rested on an SDK-internal ordering this test neither states nor controls, and if it changed the send test would silently cover the `catch` branch instead of the short-circuit it names — a regression with no failing run attached to it. Poll `/api/mcp/auth-state` instead. For a session connected without an authState there is no auth provider, so `setAuthState` always throws and `transport_error` there has exactly one source: the dead-transport short-circuit. The route never awaits a transport response, so it cannot hang either. Same cost, exact condition, no dependence on SDK internals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .../mcp/remote/remote-auth-branches.test.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) 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 27b5bc6d5..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 @@ -79,17 +79,31 @@ async function startUnauthorizedUpstream(): Promise<{ /** * Poll until the server reports the session's transport as dead (#1985). * - * The probe is a **notification** — a `method` with no `id` — which matters: - * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never - * awaits a response and cannot hang. Probing with a *request* would reproduce - * the very failure this guards against, since a request sent before the - * transport is marked dead waits forever for a reply the dead child will never - * send. + * 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. * - * Either terminal branch of the route answers `kind: "transport_error"` — the - * `isTransportDead()` short-circuit, and the `catch` around a `send()` that - * threw on the dead pipe. Both are fine as a stop condition: each one proves the - * subsequent real send also cannot reach the hanging path. + * 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 @@ -103,12 +117,14 @@ async function waitForDeadTransport( const deadline = Date.now() + timeoutMs; let last = "(no response)"; while (Date.now() < deadline) { - const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, - message: { jsonrpc: "2.0", method: "notifications/initialized" }, + authState: { + oauthTokens: { access_token: "probe", token_type: "Bearer" }, + }, }), }); const body = (await res.json()) as { kind?: string }; From b451949c9e446d8ceeece527d2e8cf58d091dcb3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:11:05 -0400 Subject: [PATCH 89/93] docs: the renderWithMantine rule no longer rests on timer safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a second-order effect of the fix: making the leaked-timer net global invalidated the rationale of a neighbouring rule. "Do not hand-roll a bare MantineProvider — that reintroduces the leak class" stopped being true the moment the net moved into setup.ts, which covers every unit test regardless of how it renders. The rule itself is still right, so keep it and replace the justification: a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Both copies say explicitly that the old reason no longer holds, so nobody re-derives it. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for review-relevant guidance — it carried the identical stale claim. That makes four places this one belief had propagated to. A fix can create stale docs by making a previously-true statement false, which is easy to miss when only the changed lines get read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c4..a0a2ddaef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.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. diff --git a/AGENTS.md b/AGENTS.md index 8584c2315..3fd9988b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **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. - **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"`, 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** (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 From c0e4b89c8de0d2322ea09b434942c59e01ec6c75 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:23:21 -0400 Subject: [PATCH 90/93] fix(test): treat timer handles as opaque; the numeric guard never matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review was right, and I had assumed the handle type rather than checking it. Probed both under happy-dom: setTimeout -> typeof "object", constructor Timeout requestAnimationFrame -> typeof "object", constructor Immediate So `typeof id === "number"` in the clearTimeout wrapper never matched, and an explicitly-cleared timer was never untracked. Both `Set<number>` annotations and `const id: number` were fictions TypeScript could not catch, since the DOM lib DECLARES number while happy-dom RETURNS an object. The effect was benign — the teardown sweep re-clears them and clearTimeout is idempotent — but the sharper problem is that the test asserting this passed for the wrong reason. "clearTimeout doesn't throw" holds whether or not tracking works, so it proved nothing and hid the broken guard. Hold handles as `unknown`, route cancellation through two small helpers that carry the one justified cast each, and drop the numeric guards. The test now asserts the net's own bookkeeping through an exported pendingTimerCount(), and is mutation-checked: restoring the numeric guard fails it (1 failed | 6 passed). That is the second test in this PR that passed without proving its claim. Both had the same tell — the assertion would have held with the feature removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/test/leakedTimers.test.tsx | 15 +++-- clients/web/src/test/setup.ts | 74 ++++++++++++++++------ 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index 209efa259..65445338b 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -16,6 +16,7 @@ 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`. */ @@ -46,11 +47,17 @@ describe("leaked-timer safety net", () => { expect(leaked.callback).not.toHaveBeenCalled(); }); - it("clearTimeout stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + 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(() => window.clearTimeout(id)).not.toThrow(); - // Clearing twice must stay a no-op — the net also clears at teardown. - expect(() => window.clearTimeout(id)).not.toThrow(); + expect(pendingTimerCount()).toBe(before + 1); + window.clearTimeout(id); + expect(pendingTimerCount()).toBe(before); }); it("queues a frame that would schedule a timer after the sweep", () => { diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index a564fa5d5..c86b36344 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -124,19 +124,48 @@ Object.defineProperty(window, "matchMedia", { // 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. -const pendingTimers = new Set<number>(); -const pendingFrames = new Set<number>(); +// 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[] -): number => { - const id: number = realSetTimeout( +): unknown => { + const id: unknown = realSetTimeout( (...cbArgs: unknown[]) => { pendingTimers.delete(id); if (typeof handler === "function") { @@ -148,34 +177,37 @@ window.setTimeout = (( ); pendingTimers.add(id); return id; - // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), - // while the DOM lib types it as `number`. The implementation above honors the - // DOM shape the app codes against; this cast bridges the two declarations, - // which TS cannot relate structurally. + // 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?: number): void => { - if (typeof id === "number") { +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); } - realClearTimeout(id); - // Same DOM-vs-Node overload mismatch as `setTimeout` above. + cancelTimer(id); + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; -window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { - const handle: number = realRequestAnimationFrame((time: number) => { +window.requestAnimationFrame = ((callback: FrameRequestCallback): unknown => { + const handle: unknown = realRequestAnimationFrame((time: number) => { pendingFrames.delete(handle); callback(time); }); pendingFrames.add(handle); return handle; - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.requestAnimationFrame; -window.cancelAnimationFrame = ((handle: number): void => { - pendingFrames.delete(handle); - realCancelAnimationFrame(handle); - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +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(() => { @@ -194,13 +226,13 @@ afterEach(() => { // 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) { - realCancelAnimationFrame(handle); + cancelFrame(handle); } pendingFrames.clear(); // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { - realClearTimeout(id); + cancelTimer(id); } pendingTimers.clear(); }); From 3e76498366ebe3dd46867084cbc2e38d28cab6d3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 08:34:08 -0400 Subject: [PATCH 91/93] chore: upgrade the MCP TypeScript SDK from 2.0.0-beta.5 to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK shipped 2.0.0 final on 2026-07-27. Shipping the Inspector against a prerelease of its core protocol library is a supply-chain and reproducibility liability we no longer have a reason to carry: a beta is eligible for unpublish and deprecation in a way a stable release is not. Bumped in the root package.json only — client, core, server, server-legacy. Node resolution walks up, so the root install already serves every client; a per-client declaration would install a second copy that drifts from it (#1970). Verified after install: one copy of each at 2.0.0, no clients/*/node_modules/ @modelcontextprotocol/* at all. ext-apps is a separate package on its own ^1.7.4 line and is untouched. The beta.5 → 2.0.0 delta is seven commits. Reviewed each for call-site impact: - core/src/schemas.ts (+16/-16) is doc-comment only — every hunk rewrites a spec URL from a commit-pinned GitHub blob to the published 2026-07-28 page. No schema surface change, so nothing in core/mcp or core/json is affected. - client 2.0.0 wants zod ^4.2.0; we are on ^4.4.3, so no zod move and no risk of the #1896 dual-copy tsc heap blowup. verify:dep-lockstep stays green and the lockfile delta is exactly the four packages with no transitive churn. - fix(validators) honors declared draft-07/06 JSON Schema dialects instead of rejecting them, which can only widen the set of tool schemas we accept. - The SSE keep-alive and legacyWrap changes are server-side, exercised by the test servers rather than by client code. The one change with real behavioral reach is #2564: the negotiation probe now classifies HTTP 401/403 as an auth failure instead of legacy-era evidence. That is the upstream fix #1807 is blocked on, and it is left for that issue — the directAuthRecovery clause in inspectorClient.ts still intercepts the challenge before the classifier sees it, so this bump does not change connect behavior. #1807 can now delete the workaround and lean on the SDK verdict, but doing it here would smuggle an auth change into a dependency bump. Also corrected the two auth as-built specs, which still claimed beta.4 — already stale before this change. v2_new_spec_impact.md is deliberately left alone: it is a dated pre-upgrade analysis, and editing its premises would rewrite the record rather than update it. npm run ci passes end to end: validate, the per-file >=90 coverage gate, verify:build-gate, all six smokes (including web:app driving connect -> open app -> data-app-status="ready"), and 472 Storybook tests. Closes #1988 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- package-lock.json | 38 +++++++++++----------- package.json | 8 ++--- specification/v2_auth_hardening.md | 2 +- specification/v2_auth_sdk_consolidation.md | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d574fdd98..5b738bc60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "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", @@ -299,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", @@ -317,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" @@ -399,12 +399,12 @@ } }, "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": { @@ -412,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", diff --git a/package.json b/package.json index c52f04c1b..b255bb284 100644 --- a/package.json +++ b/package.json @@ -78,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", 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). From 55878297d9bde86ed4de253350db6a0dc776f18b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 09:46:45 -0400 Subject: [PATCH 92/93] docs: correct the EMA spec's stale SDK-dependency claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that v2_auth_ema.md contradicted itself. Its "TypeScript SDK (implemented)" section claimed Inspector depends on `@modelcontextprotocol/sdk` v1.x only (`^1.29.0`) and that there is "no `@modelcontextprotocol/client` v2 dependency in the tree today" — while the subsection immediately below it documents which v2 client helpers the EMA legs have adopted. Checked rather than assumed: every module the section's table names imports the v2 client (emaFlow, idpOidc, resourceContext, tokenEndpoint, transportProvider, wire, and providers.ts), and there are zero v1 SDK imports in first-party code. The v1 SDK is present in the tree only as a peer pulled in by ext-apps, which AGENTS.md already states must never become a direct dependency. Rewrote the paragraph to say what is true and name the version, so it stays consistent with the two auth specs corrected in the previous commit. The per-row "v1 SDK" attributions in the table below it are wrong for the same reason, but correcting each one means auditing which SDK symbol each leg calls today — a real audit, not a version-string refresh, and out of scope for a dependency bump. Left for a follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- specification/v2_auth_ema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 27cd42b479eecfe45e572ec3b9e48afd76a5b6d4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 11:01:27 -0400 Subject: [PATCH 93/93] chore: 2.2.0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a2c7a55bc..bb4866ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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": { diff --git a/package.json b/package.json index 4650935b6..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",