From 8dc55711bf89c0fcbfd60beb2e48cc65dc4f2ec8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:32:24 -0400 Subject: [PATCH 01/73] fix(smoke): run the Firefox pass in the Playwright container on macOS 27 (#2625) Playwright's Firefox cannot launch on Darwin 27, so smoke:web:firefox timed out every local:gate run and cut off local:storybook behind it. On those hosts the runner now executes the three engine smokes inside mcr.microsoft.com/playwright:v-noble, with the tag read from clients/web/package-lock.json, over an export of the working tree. With no Docker daemon it skips loudly (naming #2625) and exits 0 so the rest of the gate runs. INSPECTOR_FIREFOX_SMOKE=native|container overrides. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/pre-push-gate/SKILL.md | 8 + docs/quality-gate.md | 2 + scripts/lib/firefox-container.mjs | 230 +++++++++++++++++++++++++ scripts/lib/firefox-container.test.mjs | 156 +++++++++++++++++ scripts/run-engine-smokes.mjs | 23 +++ 5 files changed, 419 insertions(+) create mode 100644 scripts/lib/firefox-container.mjs create mode 100644 scripts/lib/firefox-container.test.mjs diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index cb31fc1cde..0ba5fc0a9a 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -241,6 +241,14 @@ One stage has no GitHub CI counterpart, deliberately: Firefox. Trialled as a CI job and removed (#2086): across a dozen runs it never disagreed with Chromium, and `playwright install --with-deps` carries a real flake surface. Kept in front of a human about to push instead. + ⚠️ **On macOS 27 it runs inside the Linux Playwright container** (#2625): + Playwright's Firefox cannot launch there. It needs a running Docker daemon; + without one the stage prints a `SKIPPING … this is NOT a pass` warning and + exits 0 so `local:storybook` still runs — start Docker and re-run + `npm run smoke:web:firefox` before pushing. A container failure is a real + Firefox failure, not an infrastructure one, unless the log shows the image + pull or `npm install` failing. `INSPECTOR_FIREFOX_SMOKE=native|container` + overrides the routing (`scripts/lib/firefox-container.mjs`). A guard (`scripts/lib/workflow-gate.mjs`, run by `npm run test:scripts`) fails the suite if a workflow invokes a `local:*` script, a non-Chromium engine pass, diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 7168a962b0..8260b2a4d7 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -81,6 +81,8 @@ Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run loc Putting it in `npm run local:gate` instead keeps the check where it is worth most — in front of a human about to push a change they can still reason about — and pays for it once per push rather than once per CI run. If a cross-engine regression ever reaches `v2/main` because someone skipped the gate, that is the evidence for restoring the CI job; it is a one-job diff. +**On macOS 27 the Firefox pass runs in a container ([#2625](https://github.com/modelcontextprotocol/inspector/issues/2625)).** Playwright's Firefox does not launch on Darwin 27 — two builds (rev 1538 from Playwright 1.62.1, rev 1543 from 1.63.0), headless and headed — so on those hosts `smoke:web:firefox` runs the same three smokes inside `mcr.microsoft.com/playwright:v-noble`, with the tag read from `clients/web/package-lock.json`. It runs over a temporary export of the working tree (tracked and untracked-not-ignored files, uncommitted edits included), because the host `node_modules` carry macOS native binaries; the container installs and builds from scratch, so expect a few extra minutes and a one-time image pull. **With no reachable Docker daemon it skips loudly and exits 0**, printing that the skip is not a pass, so `local:storybook` behind it still runs — start Docker and re-run `npm run smoke:web:firefox` before pushing. `INSPECTOR_FIREFOX_SMOKE=native` forces a native launch (how to try a new Playwright build before retiring the container path) and `=container` forces the container on any host. Earlier macOS, Linux and Windows hosts launch natively as before. The routing lives in `scripts/lib/firefox-container.mjs`; delete it once a Playwright Firefox launches on macOS 27. + **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. > ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** It is close enough to catch engine-level CSP and iframe divergence, and not close enough to certify Safari specifically. A green run here is not a Safari guarantee. diff --git a/scripts/lib/firefox-container.mjs b/scripts/lib/firefox-container.mjs new file mode 100644 index 0000000000..4731e46b12 --- /dev/null +++ b/scripts/lib/firefox-container.mjs @@ -0,0 +1,230 @@ +/** + * Run the Firefox engine pass inside the Linux Playwright container on hosts + * where Playwright's own Firefox cannot launch (#2625). + * + * On macOS 27 (Darwin 27) both Playwright Firefox builds tried — rev 1538 + * (Playwright 1.62.1) and rev 1543 (1.63.0) — fail to launch, headless and + * headed, sandboxed or not. CI does not run the Firefox pass (#2086), so on + * those hosts the repo had no Firefox signal at all and `local:gate` went red + * at `smoke:web:firefox` every run, which also cut off `local:storybook` + * behind it in the `&&` chain. + * + * The workaround proven on the 2.10.0 release tree is the same smokes inside + * `mcr.microsoft.com/playwright:v-noble`. Three things about it are + * load-bearing: + * + * - **The image tag comes from `clients/web/package-lock.json`**, never a + * literal here. The container's preinstalled browsers belong to that + * Playwright release; a tag that drifted from the lockfile would have the + * in-container `playwright install` fetch a second build, or launch one the + * pinned client was never tested against. + * - **It runs from an export of the working tree, not a bind mount of it.** + * The host `node_modules` carry macOS native binaries that cannot load on + * Linux, and an in-container `npm install` into a mounted checkout would + * overwrite them. The export is the tracked + untracked-not-ignored files, + * so it tests what the rest of the gate tests (uncommitted edits included), + * where a `git archive HEAD` would quietly test the last commit instead. + * - **No Docker skips loudly and exits 0.** A host that cannot launch Firefox + * natively and has no container runtime has no way to produce the signal; + * failing would only stop the chain before Storybook, which is the outcome + * this module exists to remove. The message names #2625 so the skip is + * never mistaken for a pass. + * + * Retire this when a Playwright release ships a Firefox that launches on + * macOS 27: bump to it and delete the container path (#2625, step 3). + */ + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +/** The tracking issue every message here names. */ +export const ISSUE = "#2625"; + +/** + * Override for the routing decision: `container` forces the container on any + * host, `native` forces a native launch on any host. Anything else (or unset) + * leaves the decision to the host check. `native` is how to try a new + * Playwright build on macOS 27 before retiring this module. + */ +export const MODE_ENV = "INSPECTOR_FIREFOX_SMOKE"; + +/** First Darwin kernel major on which Playwright's Firefox fails to launch. */ +const FIRST_UNSUPPORTED_DARWIN = 27; + +/** + * Whether this engine pass must run in the container rather than natively. + * + * Only Firefox, and only on Darwin 27+ unless overridden. Earlier macOS hosts + * launch Playwright's Firefox fine, so routing them through a ~2GB image would + * be pure cost. + */ +export function shouldUseContainer({ engine, platform, release, env }) { + if (engine !== "firefox") return false; + const mode = env[MODE_ENV]; + if (mode === "container") return true; + if (mode === "native") return false; + if (platform !== "darwin") return false; + const major = Number.parseInt(String(release).split(".")[0], 10); + return Number.isInteger(major) && major >= FIRST_UNSUPPORTED_DARWIN; +} + +/** + * The Playwright version `clients/web` resolves, read from its lockfile text. + * + * Throws rather than guessing: a missing or malformed entry means the image + * tag cannot be derived, and a hard-coded fallback is exactly the drift this + * derivation exists to prevent. + */ +export function pinnedPlaywrightVersion(lockfileText) { + const lock = JSON.parse(lockfileText); + const version = lock?.packages?.["node_modules/playwright"]?.version; + if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error( + "clients/web/package-lock.json has no exact node_modules/playwright version", + ); + } + return version; +} + +/** The Playwright image matching a pinned Playwright version. */ +export function playwrightImage(version) { + return `mcr.microsoft.com/playwright:v${version}-noble`; +} + +/** + * The `docker run` argv for one engine pass over an exported tree. + * + * Inside the container the host check sees Linux and launches natively, so + * this cannot recurse; `MODE_ENV=native` makes that explicit rather than + * incidental. `--ipc=host` is Playwright's own recommendation for Chromium- + * family shared memory and harmless for Firefox. `CI` is not forwarded, so the + * smokes behave as they do on a developer machine. + */ +export function dockerRunArgs({ exportDir, image, engine }) { + return [ + "run", + "--rm", + "--ipc=host", + "-e", + `${MODE_ENV}=native`, + "-v", + `${exportDir}:/src`, + "-w", + "/src", + image, + "bash", + "-lc", + `npm install --no-audit --no-fund && npm run build && node scripts/run-engine-smokes.mjs ${engine}`, + ]; +} + +/** + * The files to export: tracked plus untracked-not-ignored, minus anything + * tracked but deleted in the working tree. Parsed from + * `git ls-files -z -co --exclude-standard` output. + */ +export function exportablePaths(lsFilesOutput, exists) { + const unique = new Set(lsFilesOutput.split("\0").filter(Boolean)); + return [...unique].filter((path) => exists(path)); +} + +/** Copy the working tree's exportable files from `repoRoot` into `dest`. */ +function exportWorkingTree(repoRoot, dest) { + const ls = spawnSync("git", ["ls-files", "-z", "-co", "--exclude-standard"], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (ls.status !== 0) { + throw new Error(`git ls-files failed: ${ls.stderr || ls.error?.message}`); + } + const paths = exportablePaths(ls.stdout, (p) => + existsSync(join(repoRoot, p)), + ); + for (const path of paths) { + const from = join(repoRoot, path); + const to = join(dest, path); + mkdirSync(dirname(to), { recursive: true }); + if (lstatSync(from).isSymbolicLink()) symlinkSync(readlinkSync(from), to); + else copyFileSync(from, to); + } + return paths.length; +} + +/** Whether a Docker daemon answers. `docker info` fails when only the CLI is installed. */ +function dockerAvailable() { + const probe = spawnSync("docker", ["info"], { stdio: "ignore" }); + return probe.status === 0; +} + +/** + * Run the engine pass in the container. Returns the exit status to use: + * the container's status, or 0 on a loud skip when Docker is unavailable. + */ +export function runInContainer({ repoRoot, engine }) { + const banner = `run-engine-smokes: ${engine} cannot launch natively on this host (${ISSUE})`; + + if (!dockerAvailable()) { + console.warn( + [ + "", + `⚠️ ${banner}, and no Docker daemon is reachable.`, + `⚠️ SKIPPING the ${engine} engine pass — this is NOT a pass. Start Docker`, + `⚠️ (or set ${MODE_ENV}=native to try a native launch) and re-run`, + `⚠️ \`npm run smoke:web:${engine}\` before pushing.`, + "", + ].join("\n"), + ); + return 0; + } + + const image = playwrightImage( + pinnedPlaywrightVersion( + readFileSync(join(repoRoot, "clients/web/package-lock.json"), "utf8"), + ), + ); + const exportDir = mkdtempSync(join(tmpdir(), `inspector-${engine}-smoke-`)); + try { + const count = exportWorkingTree(repoRoot, exportDir); + console.log( + `${banner}; running it in ${image} over ${count} exported files`, + ); + const run = spawnSync( + "docker", + dockerRunArgs({ exportDir, image, engine }), + { stdio: "inherit" }, + ); + if (run.error) { + console.error(`run-engine-smokes: docker failed: ${run.error.message}`); + return 1; + } + if (run.status !== 0) { + console.error( + `run-engine-smokes: the ${engine} pass failed inside ${image}`, + ); + } + return run.status ?? 1; + } finally { + // Best effort: the container wrote node_modules and builds here, and a + // leftover temp directory is not worth failing a green run over. + try { + rmSync(exportDir, { recursive: true, force: true, maxRetries: 3 }); + } catch (err) { + console.warn( + `run-engine-smokes: could not remove ${exportDir}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} diff --git a/scripts/lib/firefox-container.test.mjs b/scripts/lib/firefox-container.test.mjs new file mode 100644 index 0000000000..c3b449c21a --- /dev/null +++ b/scripts/lib/firefox-container.test.mjs @@ -0,0 +1,156 @@ +/** + * The Firefox container routing (#2625). + * + * What can rot silently here: the host check widening to hosts that launch + * Firefox fine (a ~2GB image pull for nothing), narrowing so macOS 27 is back + * to a 180s launch timeout, or the image tag drifting from the lockfile. The + * Docker invocation itself is pinned so the in-container run cannot recurse + * into another container. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + ISSUE, + MODE_ENV, + dockerRunArgs, + exportablePaths, + pinnedPlaywrightVersion, + playwrightImage, + shouldUseContainer, +} from "./firefox-container.mjs"; + +const repoRoot = join(import.meta.dirname, "..", ".."); + +describe("shouldUseContainer", () => { + const host = (overrides) => ({ + engine: "firefox", + platform: "darwin", + release: "27.0.0", + env: {}, + ...overrides, + }); + + it("routes Firefox on Darwin 27 and later", () => { + assert.equal(shouldUseContainer(host()), true); + assert.equal(shouldUseContainer(host({ release: "28.1.0" })), true); + }); + + it("launches natively on earlier macOS and on other platforms", () => { + assert.equal(shouldUseContainer(host({ release: "26.4.0" })), false); + assert.equal(shouldUseContainer(host({ platform: "linux" })), false); + assert.equal(shouldUseContainer(host({ platform: "win32" })), false); + assert.equal(shouldUseContainer(host({ release: "garbage" })), false); + }); + + it("never routes another engine, whatever the override says", () => { + for (const engine of ["chromium", "webkit"]) { + assert.equal(shouldUseContainer(host({ engine })), false); + assert.equal( + shouldUseContainer(host({ engine, env: { [MODE_ENV]: "container" } })), + false, + ); + } + }); + + it("honors the override in both directions", () => { + assert.equal( + shouldUseContainer(host({ env: { [MODE_ENV]: "native" } })), + false, + ); + assert.equal( + shouldUseContainer( + host({ platform: "linux", env: { [MODE_ENV]: "container" } }), + ), + true, + ); + // An unrecognized value falls through to the host check. + assert.equal( + shouldUseContainer(host({ env: { [MODE_ENV]: "maybe" } })), + true, + ); + }); +}); + +describe("the image tag", () => { + it("is derived from the lockfile's exact Playwright version", () => { + const lock = JSON.stringify({ + packages: { "node_modules/playwright": { version: "1.62.1" } }, + }); + assert.equal(pinnedPlaywrightVersion(lock), "1.62.1"); + assert.equal( + playwrightImage("1.62.1"), + "mcr.microsoft.com/playwright:v1.62.1-noble", + ); + }); + + it("throws rather than guessing when the lockfile has no exact version", () => { + assert.throws(() => pinnedPlaywrightVersion("{}"), /no exact/); + assert.throws( + () => + pinnedPlaywrightVersion( + JSON.stringify({ + packages: { "node_modules/playwright": { version: "^1.62.1" } }, + }), + ), + /no exact/, + ); + }); + + it("resolves from the real clients/web lockfile", () => { + const text = readFileSync( + join(repoRoot, "clients/web/package-lock.json"), + "utf8", + ); + assert.match(pinnedPlaywrightVersion(text), /^\d+\.\d+\.\d+$/); + }); +}); + +describe("dockerRunArgs", () => { + const args = dockerRunArgs({ + exportDir: "/tmp/x", + image: "img:tag", + engine: "firefox", + }); + + it("mounts the export, not the checkout, and runs the image", () => { + assert.ok(args.includes("/tmp/x:/src")); + assert.equal(args[args.indexOf("-w") + 1], "/src"); + assert.ok(args.includes("img:tag")); + assert.ok(args.includes("--rm")); + }); + + it("forces a native launch inside so the run cannot recurse", () => { + assert.ok(args.includes(`${MODE_ENV}=native`)); + }); + + it("installs, builds, then runs the same runner for the same engine", () => { + assert.match( + args.at(-1), + /^npm install .*&& npm run build && node scripts\/run-engine-smokes\.mjs firefox$/, + ); + }); +}); + +describe("exportablePaths", () => { + it("dedupes and drops tracked files deleted from the working tree", () => { + const out = "a.txt\0b/c.mjs\0gone.md\0a.txt\0"; + assert.deepEqual( + exportablePaths(out, (p) => p !== "gone.md"), + ["a.txt", "b/c.mjs"], + ); + }); + + it("handles empty output", () => { + assert.deepEqual( + exportablePaths("", () => true), + [], + ); + }); +}); + +it("names the tracking issue", () => { + assert.equal(ISSUE, "#2625"); +}); diff --git a/scripts/run-engine-smokes.mjs b/scripts/run-engine-smokes.mjs index 71883b54fa..549ec75a98 100644 --- a/scripts/run-engine-smokes.mjs +++ b/scripts/run-engine-smokes.mjs @@ -16,14 +16,24 @@ * fewer of them and still pass, so `run-engine-smokes.test.mjs` asserts that no * tier names an individual smoke. * + * On a host where the engine cannot launch natively — Firefox on macOS 27, + * #2625 — the whole pass runs inside the Linux Playwright container instead; + * see `lib/firefox-container.mjs` for when, how, and the loud skip when no + * Docker daemon is reachable. + * * Usage: `node scripts/run-engine-smokes.mjs [engine]`. With no argument the * engine comes from `SMOKE_BROWSER` (default `chromium`). */ import { spawnSync } from "node:child_process"; +import { platform, release } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { resolveRequestedBrowser } from "./install-smoke-browser.mjs"; +import { + runInContainer, + shouldUseContainer, +} from "./lib/firefox-container.mjs"; /** * The smokes that are engine-sensitive, in run order. @@ -50,6 +60,19 @@ function main() { process.exit(1); } + if ( + shouldUseContainer({ + engine: browserName, + platform: platform(), + release: release(), + env: process.env, + }) + ) { + process.exit( + runInContainer({ repoRoot: join(scriptDir, ".."), engine: browserName }), + ); + } + // Children inherit this rather than reading the ambient variable, so an // explicit argument wins over SMOKE_BROWSER all the way down. const env = { ...process.env, SMOKE_BROWSER: browserName }; From 297d5785b3b913f2d6f206010b51040c548c220c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:35:53 -0400 Subject: [PATCH 02/73] docs(pr-flow): always start work in a new git worktree (#2661) Step 2 now cuts the feature branch into a sibling worktree instead of treating a worktree as optional, with the required real `npm install` inline; step 9 removes the worktree and local branch after merge. The AGENTS.md "When work begins" rule is updated to match. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/pr-flow/SKILL.md | 51 +++++++++++++++++++++++++++++---- AGENTS.md | 2 +- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.claude/skills/pr-flow/SKILL.md b/.claude/skills/pr-flow/SKILL.md index fb17c0ee76..528d9a4b97 100644 --- a/.claude/skills/pr-flow/SKILL.md +++ b/.claude/skills/pr-flow/SKILL.md @@ -83,13 +83,41 @@ branch cut from `v2/main` arrives at `v1/main` carrying the whole v2 tree (Copilot). And never cut from a milestone-merge branch — it carries release-only commits that will show up in your PR's diff. -Working in a git worktree is fine and often preferable. ⚠️ **A worktree needs a -real `npm install`, not a symlinked `node_modules`** — a symlink passes -lint/test/coverage and then fails all Storybook story files on Vite's -`fs.allow`. +**Always cut it into a new git worktree, never by switching branches in the +primary checkout** (#2661). This repo is routinely worked by several agent +sessions on one machine at once, and they share the primary checkout: a +`git switch` there moves the branch out from under every other session using +it, and carries one task's uncommitted edits, build output and `node_modules` +state into another's. A worktree per issue gives each task its own tree and +leaves the primary checkout alone. + +Run it from the primary checkout. The worktree is a **sibling** directory named +for the checkout plus the issue number (`mcp-inspector-v2` → +`../mcp-inspector-v2-2661`), never a directory inside the repo, where every +lint, format and typecheck scope would see a second copy of the source: + +```sh +git fetch origin +git worktree add --no-track "../$(basename "$PWD")-" \ + -b v2/fix/- origin/v2/main # origin/v1/main for v1 +cd "../$(basename "$PWD")-" +npm install +``` + +- `--no-track` keeps the new branch from tracking `origin/v2/main`, so + `git status` does not report your work as "ahead of v2/main" and the first + push is an explicit `git push -u origin ` to a branch of its own name. +- ⚠️ **The `npm install` is part of the step, not optional.** A worktree starts + with no `node_modules`, and it needs a **real** install — a symlinked + `node_modules` passes lint/test/coverage and then fails all Storybook story + files on Vite's `fs.allow`. Background it if you have edits to make first; + the gate needs it finished. +- Every later step — edits, the gate, commits, the push — runs **inside the + worktree**. For order-dependent PRs on one issue, **stack** them: each branch is based on the -previous one, not all cut from `v2/main`. +previous one, not all cut from `v2/main` — so the child's worktree is cut from +the parent branch (`git worktree add … -b `). ## 3. Sign off every commit @@ -453,3 +481,16 @@ nothing else. `Done` is only for work that **shipped** — a merged PR, or a parent whose last sub-issue closed. Anything else (duplicate, won't fix, not planned, obsolete) means nothing shipped, so **delete the card** instead; see `/board-ops`. + +**Then remove the worktree and its local branch**, from the primary checkout, +once the PR shows as merged (or closed, for work abandoned). A stale worktree +keeps its branch checked out — git refuses to check that branch out anywhere +else — and holds a full `node_modules` on disk per issue: + +```sh +git worktree remove "../$(basename "$PWD")-" +git branch -D v2/fix/- +``` + +`git worktree remove` refuses a worktree with uncommitted or untracked changes; +read what is there before reaching for `--force`. diff --git a/AGENTS.md b/AGENTS.md index 76db4c1123..40b3ba32fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,7 +390,7 @@ skills; the rules are here. - **Every v2 board item has a Priority.** Priority is a **board field**, not a label, so an unboarded issue has nowhere to store it. Derive it with the rubric in the `issue-triage` skill rather than asserting it. Board #11 has no Priority field; a v1 issue gets a Status and nothing else. - **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. A `[GHSA-` **advisory draft** on #28 is exempt too, for a different reason: a draft card cannot carry a milestone, so its approval act is **accepting the advisory**, which moves it `Incoming` → `Todo`; its milestone arrives with the public issue after publication. The rest of the invariant is unchanged: assigning the milestone _is_ the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it _was_ the approval. - **`Done` means the work shipped.** Exactly two things earn a card a place in Done: its **PR merged**, or it is a **parent whose last sub-issue closed**. Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so the card is **deleted**. Done is read as the record of what a milestone actually delivered; a duplicate sitting there makes that record wrong in a way nobody can detect later. Deleting a card touches the board only — the issue keeps its labels and comments and stays searchable forever. -- **When work begins**, assign the issue to yourself, create a feature branch and set Status to **In Progress**. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves. +- **When work begins**, assign the issue to yourself, set Status to **In Progress**, and create the feature branch **in a new git worktree** — never by switching branches in the primary checkout, which concurrent agent sessions share (#2661); remove the worktree once the PR merges. The recipe is the `pr-flow` skill, step 2. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves. - **When work is complete**, run `npm run format` then `npm run local:gate`, **sign off every commit** (`git commit -s` — the repo-owned `DCO` check, `.github/workflows/dco.yml`, fails a PR on any unsigned commit with no partial credit; it gates merges into `v2/main` as a **required** status check, set by the `v2/main - DCO` repository ruleset (#2621) and pinned to GitHub Actions so only the workflow can satisfy it; a PR showing the check as missing or "expected" is an outage, not a pass), open a PR against the matching base branch with **`Closes #` as the body's first line**, and set Status to **In Review**. - **After opening a PR, run a Copilot review loop to exhaustion — unprompted.** Request a review, wait for the round to post _or_ for Copilot's session to end without one, answer every comment, and request again whenever a fix was pushed. Stop on the **first** clean round (no confirming round "just to be sure"), a round holding only out-of-scope findings, or two rounds in a row where Copilot's session ends without posting. **Weigh each finding against the issue the PR closes and decline scope expansion** — pre-existing behavior, new capabilities, and hardening the issue did not ask for — because that is what turns a review cycle into overbuilding. The recipe is the `pr-flow` skill, step 7. - **Attach screenshots as proof of functionality** for any web-UI or TUI change. Put them in a **`pr-screenshots/`** folder off the repo root — it is **gitignored**, so the images are staged for upload and never committed — and name them for what they show. From 2ba6a1ef3ae6658eccf6618c6e8081ad638f5991 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:35:57 -0400 Subject: [PATCH 03/73] fix(cli): refresh write-back updates the fresh on-disk entry (#2574) The --use-stored-auth refresh wrote back a server entry built from the snapshot read before the token grant's network round trip, so a same-server write landing meanwhile (a web OAuth flow adding an issuer slot, switching activeIssuer) was overwritten. Add updateOAuthServerEntry to oauth-persist-file: under the state-file lock it re-reads the entry fresh (store-joined) on every write attempt, applies the caller's updater, then splits and writes through the same path as writeOAuthSections. The CLI uses it to change only the refreshed slot's tokens, and never resurrects an entry or slot removed meanwhile. The rollback bookkeeping now records only fields an attempt changes, so an updater carrying forward a racing writer's secret cannot have it clobbered by a failure-path restore. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/README.md | 2 +- clients/cli/__tests__/stored-auth.test.ts | 115 ++++++++++++++++- clients/cli/src/cli.ts | 45 ++++--- .../storage/oauth-write-convergence.test.ts | 118 ++++++++++++++++++ core/auth/node/oauth-persist-file.ts | 105 ++++++++++++++-- 5 files changed, 351 insertions(+), 34 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 16803fa023..5e8b1fd444 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -430,7 +430,7 @@ For the common case where OAuth was already completed in the **web inspector on **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. -**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. +**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The write-back updates only the refreshed issuer slot's tokens, against the entry as it is on disk at write time, so a same-server change made while the grant was in flight (say, a web OAuth flow against another authorization server) is kept rather than overwritten (#2574). The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover. diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 1755cf5b70..7167720863 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -22,7 +22,11 @@ import { type StoredServers, } from "../src/cli.js"; import { SecretFileLockHeldError } from "@inspector/core/auth/node/secret-store.js"; -import { readOAuthStore } from "@inspector/core/auth/node/oauth-persist-file.js"; +import { + readOAuthStore, + writeOAuthSections, +} from "@inspector/core/auth/node/oauth-persist-file.js"; +import type { ServerOAuthState } from "@inspector/core/auth/store.js"; import { oauthSecretServerId } from "@inspector/core/auth/node/oauth-secrets.js"; import { defaultSecretStore } from "@inspector/core/auth/node/secret-store-selection.js"; import { @@ -1325,6 +1329,115 @@ describe("issuer-keyed stored auth (#2517)", () => { } }); + // #2574: the write-back lands after a network round trip, so a same-server + // write made meanwhile — here a web OAuth flow against a second AS — must + // survive it. Only the refreshed slot's tokens may change. + describe("concurrent same-server writes during the grant (#2574)", () => { + const SLOT = { + tokens: { refresh_token: "active-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "active-client" }, + }; + const ROTATED = { + access_token: "rotated-access", + token_type: "Bearer", + refresh_token: "rotated-refresh", + }; + const fixture = () => + writeOAuthFixture({ + [SERVER]: { + activeIssuer: ISSUER, + byIssuer: { [ISSUER]: SLOT }, + serverMetadata: { + issuer: ISSUER, + token_endpoint: `${ISSUER}/token`, + }, + }, + }); + + /** A refresh grant that lets `during` rewrite the server mid-flight. */ + const refreshWhile = ( + path: string, + during: (entry: ServerOAuthState) => ServerOAuthState | undefined, + ) => + vi.fn(async () => { + const current = (await readOAuthStore(path))!.servers[SERVER]!; + const next = during(current); + await writeOAuthSections( + path, + { + servers: next === undefined ? {} : { [SERVER]: next }, + idpSessions: {}, + }, + { servers: [SERVER] }, + ); + return ROTATED; + }); + + it("keeps an issuer slot and activeIssuer change made during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + activeIssuer: OTHER, + byIssuer: { + ...entry.byIssuer, + [OTHER]: { + tokens: { access_token: "web-access", token_type: "Bearer" }, + clientInformation: { client_id: "web-client" }, + }, + }, + })); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + // The web flow's write survives… + expect(persisted?.activeIssuer).toBe(OTHER); + expect(persisted?.byIssuer?.[OTHER]?.tokens?.access_token).toBe( + "web-access", + ); + // …and the rotation still lands in the slot it was read from. + expect(persisted?.byIssuer?.[ISSUER]).toEqual({ + ...SLOT, + tokens: ROTATED, + }); + } finally { + rmSync(path, { force: true }); + } + }); + + it("does not resurrect a server cleared during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, () => undefined); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + expect((await readOAuthStore(path))?.servers[SERVER]).toBeUndefined(); + } finally { + rmSync(path, { force: true }); + } + }); + + it("does not resurrect an issuer slot removed during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + activeIssuer: undefined, + byIssuer: {}, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]).toBeUndefined(); + expect(persisted?.tokens).toBeUndefined(); + } finally { + rmSync(path, { force: true }); + } + }); + }); + it("reports no_client_information when the active slot has a refresh token but no client", async () => { const path = writeOAuthFixture({ [SERVER]: { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 34653b7f1e..ba65b48e73 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -70,7 +70,7 @@ import type { ServerOAuthState } from "@inspector/core/auth/store.js"; import { getOwnEntry } from "@inspector/core/storage/own-entry.js"; import { readOAuthStore, - writeOAuthSections, + updateOAuthServerEntry, } from "@inspector/core/auth/node/oauth-persist-file.js"; import { discoverAuthorizationServerMetadataFromCandidates, @@ -460,8 +460,7 @@ export async function refreshStoredAuthToken( fetchFn: options?.fetchFn ?? storedAuthFetch, })); - const snapshot = await readOAuthSnapshot(statePath); - const servers = snapshot.servers as StoredServers; + const servers = await readOAuthServers(statePath); const found = findStoredServerState(servers, serverUrl); const credentials: StoredCredentials = found ? resolveStoredCredentials(found.state) @@ -523,30 +522,30 @@ export async function refreshStoredAuthToken( ); } - // Persist the rotated tokens back under the same key via the shared - // sectioned write: lock → fresh read → overlay just this server's entry → - // atomic write. This generalizes the read-modify-write this function used - // to hand-roll — the merge now happens against the file as it is at write - // time (not the snapshot read before the network round-trip), under the - // same cross-process lock every other writer uses, and keeps the file's - // owner-only `0o600` mode + `mkdir -p` via the shared store IO. + // Persist the rotated tokens back under the same key, against the entry as + // it is on disk NOW — not the snapshot read before the network round trip. + // The update runs under the shared state-file lock on a fresh, store-joined + // read, so a same-server write that landed during the refresh (a web OAuth + // flow adding an issuer slot, switching `activeIssuer`, saving new tokens) + // survives: only the refreshed slot's `tokens` change (#2574). // // The rotated tokens go back to the slot they were read from: the active // issuer's `byIssuer` entry (#2517), or the legacy top-level fields for a - // pre-issuer entry — never a slot the old tokens did not come from. + // pre-issuer entry — never a slot the old tokens did not come from. A slot + // or entry removed meanwhile stays removed; the fresh token is still + // returned for this run. const { issuer } = credentials; - servers[found.key] = - issuer !== undefined - ? { - ...found.state, - byIssuer: { - ...found.state.byIssuer, - // A computed key defines an own property, so `__proto__` is safe. - [issuer]: { ...getOwnEntry(found.state.byIssuer, issuer), tokens }, - }, - } - : { ...found.state, tokens }; - await writeOAuthSections(statePath, snapshot, { servers: [found.key] }); + await updateOAuthServerEntry(statePath, found.key, (fresh) => { + if (fresh === undefined) return undefined; + if (issuer === undefined) return { ...fresh, tokens }; + const slot = getOwnEntry(fresh.byIssuer, issuer); + if (slot === undefined) return fresh; + return { + ...fresh, + // A computed key defines an own property, so `__proto__` is safe. + byIssuer: { ...fresh.byIssuer, [issuer]: { ...slot, tokens } }, + }; + }); return tokens.access_token; } diff --git a/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts b/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts index f733d8dc7c..bea288f092 100644 --- a/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts +++ b/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts @@ -7,6 +7,10 @@ * verify/re-apply pattern as `FileSecretStore.mutateLocked`. These tests * simulate the racing writer with a hook that rewrites the file immediately * after each `writeStoreFile`. + * + * `updateOAuthServerEntry` (#2574) rides the same loop, so its fresh-read + * semantics are pinned here too: the updater re-runs over whatever the + * racing writer left, and a rollback never clobbers that writer's secrets. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; @@ -16,6 +20,7 @@ import { join } from "node:path"; import { writeOAuthSections, readOAuthStore, + updateOAuthServerEntry, } from "@inspector/core/auth/node/oauth-persist-file.js"; import { InMemorySecretStore, @@ -608,3 +613,116 @@ describe("writeOAuthSections convergence verification", () => { warn.mockRestore(); }); }); + +describe("updateOAuthServerEntry", () => { + const NEW_TOKENS = { + access_token: "at-new", + token_type: "Bearer", + refresh_token: "rt-new", + }; + + it("hands the updater the store-joined entry and leaves other servers alone", async () => { + await writeOAuthSections( + filePath, + snapshotOf({ [SERVER_B]: serverState("b") }), + { servers: [SERVER_B] }, + store, + ); + const update = vi.fn((entry) => ({ ...entry!, tokens: NEW_TOKENS })); + + await updateOAuthServerEntry(filePath, SERVER_A, update, store); + + // The updater saw the secrets, not just the file residue. + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0]![0]).toEqual(serverState("a")); + const read = await readOAuthStore(filePath, store); + expect(read?.servers[SERVER_A]).toEqual({ + ...serverState("a"), + tokens: NEW_TOKENS, + }); + expect(read?.servers[SERVER_B]).toEqual(serverState("b")); + }); + + it("passes undefined for an absent entry, and an undefined result writes nothing for it", async () => { + const update = vi.fn(() => undefined); + + await updateOAuthServerEntry(filePath, SERVER_B, update, store); + + expect(update).toHaveBeenCalledWith(undefined); + const read = await readOAuthStore(filePath, store); + expect(Object.keys(read?.servers ?? {})).toEqual([SERVER_A]); + expect(read?.servers[SERVER_A]).toEqual(serverState("a")); + }); + + it("removes the entry and its secrets when the updater returns undefined", async () => { + await updateOAuthServerEntry(filePath, SERVER_A, () => undefined, store); + + const read = await readOAuthStore(filePath, store); + expect(read?.servers[SERVER_A]).toBeUndefined(); + expect(await store.get(idOf(SERVER_A), LEGACY_TOKENS_FIELD)).toBeNull(); + expect( + await store.get(idOf(SERVER_A), LEGACY_CLIENT_SECRET_FIELD), + ).toBeNull(); + }); + + it("re-runs the updater over a racing writer's entry instead of overwriting it", async () => { + // The racing writer changed A's scope after our first attempt read the + // file. A whole-entry write from a stale snapshot would drop that; the + // retry must rebuild from what the racer left. + const racing = JSON.parse(onlyA) as { + servers: Record; + }; + racing.servers[SERVER_A]!.scope = "read write"; + let clobbers = 0; + hook.afterWrite = (path) => { + if (clobbers++ === 0) writeFileSync(path, JSON.stringify(racing)); + }; + const update = vi.fn((entry) => ({ ...entry!, tokens: NEW_TOKENS })); + + await updateOAuthServerEntry(filePath, SERVER_A, update, store); + + expect(update).toHaveBeenCalledTimes(2); + const read = await readOAuthStore(filePath, store); + expect(read?.servers[SERVER_A]?.scope).toBe("read write"); + expect(read?.servers[SERVER_A]?.tokens).toEqual(NEW_TOKENS); + }); + + it("rolls back without clobbering a secret a racing writer changed between attempts", async () => { + // Attempt 1 is clobbered by a writer that also rotated A's client + // secret; attempt 2 carries that secret forward unchanged and then fails + // to write the file. The rollback must restore the racer's secret, not + // the pre-call one — the updater carried it forward, it did not write it. + let writes = 0; + hook.beforeWrite = () => { + writes += 1; + if (writes === 2) throw new Error("disk full"); + }; + // The racer also edits the residue: a token-only change leaves the file + // bytes as they were, and attempt 1's read-back would then match. + const racing = JSON.parse(onlyA) as { + servers: Record; + }; + racing.servers[SERVER_A]!.scope = "read write"; + hook.afterWrite = async (path) => { + if (writes !== 1) return; + writeFileSync(path, JSON.stringify(racing)); + await store.set(idOf(SERVER_A), LEGACY_CLIENT_SECRET_FIELD, "cs-racer"); + }; + + await expect( + updateOAuthServerEntry( + filePath, + SERVER_A, + (entry) => ({ ...entry!, tokens: NEW_TOKENS }), + store, + ), + ).rejects.toThrow(/disk full/); + + expect(await store.get(idOf(SERVER_A), LEGACY_CLIENT_SECRET_FIELD)).toBe( + "cs-racer", + ); + // The token write this call did make is unwound to the pre-call value. + const read = await readOAuthStore(filePath, store); + expect(read?.servers[SERVER_A]?.tokens).toEqual(serverState("a").tokens); + }); +}); diff --git a/core/auth/node/oauth-persist-file.ts b/core/auth/node/oauth-persist-file.ts index 35bf7da60b..1002e7e90d 100644 --- a/core/auth/node/oauth-persist-file.ts +++ b/core/auth/node/oauth-persist-file.ts @@ -49,6 +49,7 @@ import { type OAuthPersistSections, type OAuthPersistSnapshot, } from "../oauth-persist.js"; +import type { ServerOAuthState } from "../store.js"; import { withSecretFileLock } from "./file-lock.js"; import { restoreSecretFields, @@ -594,6 +595,81 @@ export async function writeOAuthSections( snapshot: OAuthPersistSnapshot, sections?: OAuthPersistSections, secretStore: SecretStore = defaultSecretStore(), +): Promise { + await writeOAuthSectionsFrom( + filePath, + async () => snapshot, + sections, + secretStore, + ); +} + +/** + * Atomically update one server entry against its **fresh** on-disk value: + * lock → fresh read (rejoined with the store) → `update(entry)` → the same + * split and write as {@link writeOAuthSections}. `update` re-runs on every + * write attempt, each time over the entry as it is then, so a concurrent + * same-server write that lands while the caller was off the lock — a + * network round trip, say — is built on rather than overwritten (#2574). + * {@link writeOAuthSections} cannot give that guarantee: it merges at + * whole-entry granularity, so the entry it writes is whatever the caller + * read before it let go of the lock. + * + * `update` receives `undefined` when the file has no such entry, and a + * returned `undefined` removes the entry. It must be pure: it can run more + * than once per call. + */ +export async function updateOAuthServerEntry( + filePath: string, + serverKey: string, + update: (entry: ServerOAuthState | undefined) => ServerOAuthState | undefined, + secretStore: SecretStore = defaultSecretStore(), +): Promise { + await writeOAuthSectionsFrom( + filePath, + async (disk, namespace) => { + const diskEntry = getOwnEntry(disk?.servers, serverKey); + const fresh = + diskEntry === undefined + ? undefined + : getOwnEntry( + ( + await joinSnapshot( + { + servers: Object.fromEntries([[serverKey, diskEntry]]), + idpSessions: {}, + }, + secretStore, + namespace, + ) + ).servers, + serverKey, + ); + const next = update(fresh); + return { + servers: + next === undefined ? {} : Object.fromEntries([[serverKey, next]]), + idpSessions: {}, + }; + }, + { servers: [serverKey] }, + secretStore, + ); +} + +/** + * The locked write both entry points share. `resolveSnapshot` is called once + * per write attempt, after the fresh disk read and namespace convergence, so + * an updater sees the state it is about to overwrite. + */ +async function writeOAuthSectionsFrom( + filePath: string, + resolveSnapshot: ( + disk: OAuthPersistSnapshot | null, + namespace: string, + ) => Promise, + sections: OAuthPersistSections | undefined, + secretStore: SecretStore, ): Promise { const policy = getPersistTokensPolicy(); const durable = await secretStoreIsDurable(secretStore); @@ -616,11 +692,15 @@ export async function writeOAuthSections( // attempt as committed: a brand-new entry's secrets would stay in the // store with no file index for `removeOAuthStore` to find. const restoreBaseline = new Map(); - // What this call writes per (server, field) — constant across attempts, - // because `secrets` derives from the caller's snapshot and the policy, - // never from disk; a candidate outside `secrets` is deleted (null). - // This is what lets the fold below tell "our own earlier attempt's - // write" apart from "a concurrent writer's newer value". + // What this call writes per (server, field); a candidate outside + // `secrets` is deleted (null). This is what lets the fold below tell + // "our own earlier attempt's write" apart from "a concurrent writer's + // newer value". For server entries it is recorded only for fields an + // attempt actually CHANGES: an updater (`updateOAuthServerEntry`) + // rebuilds its snapshot from the fresh store each attempt, so a field it + // merely carries forward may hold a concurrent writer's value — counting + // that as ours would keep the older baseline and let a rollback clobber + // it. const ourWrites = new Map(); const priorKey = (p: { serverId: string; field: string }): string => `${p.serverId}\u0000${p.field}`; @@ -796,6 +876,10 @@ export async function writeOAuthSections( unconfirmed = null; namespace = diskNamespace; } + // Resolved per attempt, after convergence: an updater must see the + // entry this attempt is about to overwrite, under the namespace its + // secrets are actually stored at. + const snapshot = await resolveSnapshot(disk, namespace); // Deduplicated: caller-passed sections may repeat a URL/issuer, and a // second pass over the same entry would snapshot the value the first // pass just wrote — a rollback would then "restore" that intermediate @@ -872,11 +956,14 @@ export async function writeOAuthSections( continue; } const { residue, secrets } = splitServerOAuthState(next, policy); + const entryPriorValue = new Map( + entryPrior.map(({ field, value }) => [field, value]), + ); for (const field of candidates) { - ourWrites.set( - priorKey({ serverId, field }), - secrets[field] ?? null, - ); + const value = secrets[field] ?? null; + // Changed fields only — see `ourWrites`. + if (value === entryPriorValue.get(field)) continue; + ourWrites.set(priorKey({ serverId, field }), value); } // Own-property writes throughout: URL/issuer keys are untrusted // and "__proto__" would otherwise silently drop the residue. From 658d160d2a99e573b4a2dc0e6dfaa1fab5719686 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:36:19 -0400 Subject: [PATCH 04/73] test(web): bind auto-open server to :0 instead of probing a free port (#2602) findFreePort() closed its probe socket before startHonoServer bound the port, so under load another process could take it and the server's EADDRINUSE handler process.exit(1)'d the run. Bind :0 and read the real port back from the URL handed to the opener, which the banner builds from the bound port. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../server/server-auto-open.test.ts | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/clients/web/src/test/integration/server/server-auto-open.test.ts b/clients/web/src/test/integration/server/server-auto-open.test.ts index c152ba3de6..c820081389 100644 --- a/clients/web/src/test/integration/server/server-auto-open.test.ts +++ b/clients/web/src/test/integration/server/server-auto-open.test.ts @@ -16,7 +16,6 @@ */ import { describe, it, expect, afterEach, vi } from "vitest"; import { EventEmitter } from "node:events"; -import { createServer } from "node:net"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -45,23 +44,6 @@ function fakeChild(outcome: "spawn" | Error = "spawn"): EventEmitter { return child; } -async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = createServer(); - srv.unref(); - srv.on("error", reject); - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address(); - if (addr && typeof addr === "object") { - const { port } = addr; - srv.close(() => resolve(port)); - } else { - srv.close(() => reject(new Error("Could not resolve a free port"))); - } - }); - }); -} - /** An unhandledRejection fires on a later macrotask than the rejection. */ async function settle() { await new Promise((resolve) => setTimeout(resolve, 50)); @@ -136,10 +118,13 @@ describe("startHonoServer autoOpen", () => { staticRoot = await mkdtemp(join(tmpdir(), "inspector-autoopen-")); await writeFile(join(staticRoot, "index.html"), INDEX_HTML, "utf-8"); - const port = await findFreePort(); - const baseUrl = `http://127.0.0.1:${port}`; + // Bind `:0` and let the kernel pick, so the port is owned from the + // moment it is chosen. Probing for a free port and binding it later + // leaves a window where another process on a loaded machine takes it, + // and the server's EADDRINUSE handler then calls process.exit(1) + // (#2602). const config: WebServerConfig = { - port, + port: 0, hostname: "127.0.0.1", authToken: "auto-open-token", dangerouslyOmitAuth: false, @@ -148,7 +133,9 @@ describe("startHonoServer autoOpen", () => { writable: true, initialServers: null, storageDir: undefined, - allowedOrigins: [baseUrl], + // The origin is unknown until the bind; this test never calls /api, + // which is the only surface the allow-list guards. + allowedOrigins: [], sandboxPort: 0, appOriginPort: 0, sandboxHost: "127.0.0.1", @@ -164,7 +151,13 @@ describe("startHonoServer autoOpen", () => { } await settle(); - expect(openMock).toHaveBeenCalledWith(expect.stringContaining(baseUrl)); + // The URL handed to the opener is the banner URL, built from the port + // the server actually bound — so it is also where the server is. + expect(openMock).toHaveBeenCalledTimes(1); + const opened = new URL(String(openMock.mock.calls[0][0])); + expect(opened.hostname).toBe("127.0.0.1"); + expect(Number(opened.port)).toBeGreaterThan(0); + const baseUrl = opened.origin; expect(unhandled).toEqual([]); expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error)); // The point of warning rather than throwing: the server is still up. From 14588f5063bcd7472a4f6967a16308a0071ac41e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:41:08 -0400 Subject: [PATCH 05/73] fix(gate-lease): key the lock on a fixed per-user path, not TMPDIR (#2624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lease lived under os.tmpdir(), which follows $TMPDIR, so a gate run as `TMPDIR=/tmp npm run local:gate` took a different lock and ran alongside a gate already holding the lease — the overlap #2339 exists to prevent. leaseDir() now returns /tmp/mcp-inspector-gate-lease- on POSIX and a directory under the account's profile (from os.userInfo(), not %TEMP%) on Windows. $XDG_RUNTIME_DIR is dropped for the same reason: a desktop session sets it and an ssh/tmux one may not. INSPECTOR_GATE_LEASE_DIR still overrides. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- docs/quality-gate.md | 4 +-- scripts/gate-lease.mjs | 63 +++++++++++++++++++++++++++++++------ scripts/gate-lease.test.mjs | 58 ++++++++++++++++++++++++++++++---- 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 7168a962b0..092808255f 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -93,11 +93,11 @@ The machine this repo is worked on runs three or four agent sessions at once, ea ### What the lease does -`npm run local:gate` is `node scripts/gate-lease.mjs npm run local:gate:stages`: the stage chain runs as the child of a wrapper that holds **one machine-wide lease for the whole run**. The lock lives under `os.tmpdir()` — or `$XDG_RUNTIME_DIR` where a desktop session sets it — and never inside a worktree, because a lock in the repo would be one per worktree, which is one per session, which coordinates nothing. A second gate started in another worktree while one holds the lease **waits**: it prints one line naming the holder's pid, worktree and elapsed time, re-asks every 2s, says `still waiting` once a minute, and starts the moment the holder releases. +`npm run local:gate` is `node scripts/gate-lease.mjs npm run local:gate:stages`: the stage chain runs as the child of a wrapper that holds **one machine-wide lease for the whole run**. The lock lives at one fixed per-user path — `/tmp/mcp-inspector-gate-lease-` on POSIX, `AppData\Local\mcp-inspector-gate-lease` under the account's profile directory on Windows (looked up from the account, not from `%TEMP%`) — and never inside a worktree, because a lock in the repo would be one per worktree, which is one per session, which coordinates nothing. For the same reason no per-session environment variable moves it: it used to follow `os.tmpdir()`, so a gate run as `TMPDIR=/tmp npm run local:gate` took a different lock and ran straight alongside one already holding the lease, and both went red on diffs that could not have caused it ([#2624](https://github.com/modelcontextprotocol/inspector/issues/2624)). `INSPECTOR_GATE_LEASE_DIR` still overrides the path outright, for a shared box that needs it elsewhere — set it for every gate or for none. A second gate started in another worktree while one holds the lease **waits**: it prints one line naming the holder's pid, worktree and elapsed time, re-asks every 2s, says `still waiting` once a minute, and starts the moment the holder releases. **Waiters are served in arrival order** ([#2473](https://github.com/modelcontextprotocol/inspector/issues/2473)). Before that, every waiter polled the lock and whichever poll landed first after a release won, so the gate queued longest had no advantage over one that arrived a second ago — and since the wait budget below is a total, an old waiter could lose race after race and give up while newer gates ran (observed with six gates queued at once). Now each waiter first writes a *ticket* to `queue/` beside the lock, named by its arrival time, and only the waiter whose ticket sorts first among the live ones asks for the lock at all; the lock still does the granting. The waiting line says how many gates are queued ahead. A ticket whose process is gone is pruned by the next waiter at its next poll, and one that goes 30s without a refresh is pruned regardless, so a waiter that is Ctrl-C'd or killed stops holding up the line almost at once. A live waiter whose ticket a peer pruned puts it back under the same name and keeps its place. A queue directory that cannot be written costs only the ordering: the waiter still takes the lease, out of turn, and says so. -The lease is released on every way out — a normal exit, a spawn failure, or a signal. On POSIX the wrapper runs the chain in its own process group, so Ctrl-C, a plain `kill`, a closed terminal or Ctrl-\ stop the whole tree (`npm run` nests four deep and `sh` forwards nothing), release, and exit `128 + signal`; on Windows there is no process group (`spawnSpec` spawns through `cmd.exe`, not detached), the signal is delivered to the immediate child only, and the lease is released just the same — what is not guaranteed there is that every descendant of that child has stopped. A holder that dies without releasing — a killed terminal, an OOM'd session — stops refreshing its lock and is taken over after **30s** (`proper-lockfile`'s stale detection, at 30s rather than the library's 10s default: a refresh timer firing late under the very load this exists to manage would otherwise cause a false takeover, which is two gates overlapping — the thing being prevented — whereas the cost of the longer window is 20 more seconds after a crash, against a gate that takes minutes). A waiter gives up after **45 minutes**, naming the holder and the lock path. That budget is a **total queueing budget**: it counts from the waiter's first attempt and is not reset when the holder ahead releases and another queued gate takes the lease, so it expires against a live gate that has hung, a dead holder's lock directory that cannot be removed, or a queue deeper than the budget covers — about ten gates at ~4.5 minutes each (see [capacity](#what-it-means-for-capacity)). A give-up behind a queue of healthy gates is that third case, not a hung holder; the line it prints names whichever gate holds the lease at that moment. `INSPECTOR_SKIP_GATE_LEASE=1` bypasses the lease — for a measurement that *needs* contention, never for a sooner result, since the queued run finishes before an overlapped one would. And the lease adds only one way for the wrapper itself to exit red — that 45-minute give-up, which happens before any stage is spawned. A lock that cannot be *created* (an unwritable tmpdir) degrades to an unleased run with a warning rather than failing, and every handled exit *attempts* the release; if the release itself fails, the wrapper says so and leaves the lock for the stale takeover, or for a hand removal if whatever blocked it persists. +The lease is released on every way out — a normal exit, a spawn failure, or a signal. On POSIX the wrapper runs the chain in its own process group, so Ctrl-C, a plain `kill`, a closed terminal or Ctrl-\ stop the whole tree (`npm run` nests four deep and `sh` forwards nothing), release, and exit `128 + signal`; on Windows there is no process group (`spawnSpec` spawns through `cmd.exe`, not detached), the signal is delivered to the immediate child only, and the lease is released just the same — what is not guaranteed there is that every descendant of that child has stopped. A holder that dies without releasing — a killed terminal, an OOM'd session — stops refreshing its lock and is taken over after **30s** (`proper-lockfile`'s stale detection, at 30s rather than the library's 10s default: a refresh timer firing late under the very load this exists to manage would otherwise cause a false takeover, which is two gates overlapping — the thing being prevented — whereas the cost of the longer window is 20 more seconds after a crash, against a gate that takes minutes). A waiter gives up after **45 minutes**, naming the holder and the lock path. That budget is a **total queueing budget**: it counts from the waiter's first attempt and is not reset when the holder ahead releases and another queued gate takes the lease, so it expires against a live gate that has hung, a dead holder's lock directory that cannot be removed, or a queue deeper than the budget covers — about ten gates at ~4.5 minutes each (see [capacity](#what-it-means-for-capacity)). A give-up behind a queue of healthy gates is that third case, not a hung holder; the line it prints names whichever gate holds the lease at that moment. `INSPECTOR_SKIP_GATE_LEASE=1` bypasses the lease — for a measurement that *needs* contention, never for a sooner result, since the queued run finishes before an overlapped one would. And the lease adds only one way for the wrapper itself to exit red — that 45-minute give-up, which happens before any stage is spawned. A lock that cannot be *created* (an unwritable lease directory) degrades to an unleased run with a warning rather than failing, and every handled exit *attempts* the release; if the release itself fails, the wrapper says so and leaves the lock for the stale takeover, or for a hand removal if whatever blocked it persists. ### Why a queue, and not a load-based wait or a worker cap diff --git a/scripts/gate-lease.mjs b/scripts/gate-lease.mjs index 5bb34c5c76..5f45709c2e 100644 --- a/scripts/gate-lease.mjs +++ b/scripts/gate-lease.mjs @@ -52,16 +52,19 @@ * pruned it, so a starved event loop costs it nothing but a moment, not its * place. * - * **Why the lease is machine-wide.** The lock lives under `os.tmpdir()` - * (`$XDG_RUNTIME_DIR` where a desktop session sets it), never inside a - * worktree — a lock in the repo would be one per worktree, which is one per - * session, which coordinates nothing. + * **Why the lease is machine-wide.** The lock lives at one fixed per-user + * path ({@link leaseDir}), never inside a worktree — a lock in the repo would + * be one per worktree, which is one per session, which coordinates nothing. + * For the same reason no environment variable a session might set moves it: + * it used to live under `os.tmpdir()`, which follows `$TMPDIR`, so a gate run + * as `TMPDIR=/tmp npm run local:gate` took a different lock and ran straight + * alongside one that was already holding the lease (#2624). * * **Why default-on.** The sessions that need it are the ones that did not * think to opt in. `INSPECTOR_SKIP_GATE_LEASE=1` bypasses it, and the first * "waiting" line names the holder, its worktree, and that variable, so a wait * is never a mystery. It is also never a gate: a lock that cannot be *created* - * (an unwritable tmpdir) runs the command unleased with a warning, because a + * (an unwritable lease directory) runs the command unleased with a warning, because a * coordination aid must not acquire a new way to fail the gate it wraps. * * **Why the whole gate and not its parallel stages.** The serial stages are @@ -90,7 +93,7 @@ import nodeFs, { utimesSync, writeFileSync, } from "node:fs"; -import { hostname, constants as osConstants, tmpdir } from "node:os"; +import { hostname, constants as osConstants, tmpdir, userInfo } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; @@ -142,10 +145,52 @@ export const PROGRESS_MS = 60_000; */ export const MAX_WAIT_MS = 45 * 60_000; -/** Where the lease lives. `$XDG_RUNTIME_DIR` is per-user and per-session where it exists. */ -export function leaseDir(env = process.env) { +/** + * Where the lease lives: one path per user, which no per-session environment + * variable moves (#2624). Every gate on the machine has to agree on it, so + * anything a single session can set differently is disqualified — `$TMPDIR` + * (what `os.tmpdir()` follows, and which the #2609 workaround set), and + * `$XDG_RUNTIME_DIR` too, which a desktop terminal sets and an `ssh` or bare + * `tmux` session may not. + * + * - **POSIX:** `/tmp/mcp-inspector-gate-lease-`. `/tmp` is the one + * temporary directory every POSIX system has at a fixed path, and the uid + * suffix keeps two users of a shared box from contending for one directory + * neither can remove. A directory another user planted there that this one + * cannot write degrades to an unleased run with a warning, like any other + * unusable lease directory. + * - **Windows:** under the account's profile directory, read from the account + * (`os.userInfo()`) rather than from `%USERPROFILE%`, `%TEMP%` or `%TMP%`. + * Only if that lookup fails does it fall back to `os.tmpdir()`. + * + * {@link DIR_ENV} still overrides it outright: that is a deliberate choice, + * made by whoever sets it for every gate, not an accident of one session. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {{ platform?: NodeJS.Platform, uid?: () => number, home?: () => string }} [host] + * injectable for the tests, which must cover the platform they are not on + */ +export function leaseDir( + env = process.env, + { + platform = process.platform, + uid = () => process.getuid(), + home = () => userInfo().homedir, + } = {}, +) { if (env[DIR_ENV]) return path.resolve(env[DIR_ENV]); - return path.join(env.XDG_RUNTIME_DIR || tmpdir(), "mcp-inspector-gate-lease"); + if (platform !== "win32") { + return path.posix.join("/tmp", `mcp-inspector-gate-lease-${uid()}`); + } + let base; + try { + base = path.win32.join(home(), "AppData", "Local"); + } catch { + // An account with no profile record. Nothing better is reachable, and an + // unleased gate is worse than one keyed on %TEMP%. + base = tmpdir(); + } + return path.win32.join(base, "mcp-inspector-gate-lease"); } /** diff --git a/scripts/gate-lease.test.mjs b/scripts/gate-lease.test.mjs index c1964f3d7a..5d5b89c090 100644 --- a/scripts/gate-lease.test.mjs +++ b/scripts/gate-lease.test.mjs @@ -20,7 +20,7 @@ import { writeFileSync, } from "node:fs"; import { hostname, tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve, win32 } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; @@ -108,13 +108,59 @@ function isAlive(pid) { } } -test("leaseDir: explicit override, then XDG_RUNTIME_DIR, then tmpdir", () => { - assert.equal(leaseDir({ [DIR_ENV]: "/x/y" }), "/x/y"); +test("leaseDir: the explicit override wins", () => { + assert.equal(leaseDir({ [DIR_ENV]: "/x/y" }), resolve("/x/y")); +}); + +test("leaseDir: POSIX is a fixed per-uid path under /tmp, whatever the session sets (#2624)", () => { + const posix = { platform: "darwin", uid: () => 501 }; + const expected = "/tmp/mcp-inspector-gate-lease-501"; + // The #2624 overlap: one gate under the default macOS TMPDIR, one under + // `TMPDIR=/tmp`. Both — and an XDG desktop session — must land on one lock. + for (const env of [ + {}, + { TMPDIR: "/var/folders/xy/abc/T/" }, + { TMPDIR: "/tmp" }, + { XDG_RUNTIME_DIR: "/run/user/501" }, + ]) { + assert.equal(leaseDir(env, posix), expected, JSON.stringify(env)); + } + assert.equal( + leaseDir({}, { platform: "linux", uid: () => 1000 }), + "/tmp/mcp-inspector-gate-lease-1000", + ); +}); + +test("leaseDir: on this host it does not follow TMPDIR", () => { + // Undefined `env` would default to process.env; pass two that differ only + // in TMPDIR and check the real-host default ignores the difference. + assert.equal(leaseDir({ TMPDIR: "/a" }), leaseDir({ TMPDIR: "/b" })); +}); + +test("leaseDir: Windows keys on the account profile, not %TEMP%", () => { assert.equal( - leaseDir({ XDG_RUNTIME_DIR: "/run/user/1" }), - join("/run/user/1", "mcp-inspector-gate-lease"), + leaseDir( + { TEMP: "D:\\elsewhere", TMP: "D:\\elsewhere" }, + { platform: "win32", home: () => "C:\\Users\\dev" }, + ), + "C:\\Users\\dev\\AppData\\Local\\mcp-inspector-gate-lease", ); - assert.equal(leaseDir({}), join(tmpdir(), "mcp-inspector-gate-lease")); + // No profile record: fall back to tmpdir rather than run unleased. + assert.equal( + leaseDir( + {}, + { + platform: "win32", + home: () => { + throw new Error("no passwd entry"); + }, + }, + ), + win32.join(tmpdir(), "mcp-inspector-gate-lease"), + ); +}); + +test("lockPathOf: the lock is a sibling of the target", () => { // The lock is a sibling of the target, never inside it — a file inside the // lock directory would make its removal (and so stale takeover) fail. assert.equal(lockPathOf("/d"), `${leaseTarget("/d")}.lock`); From 4285e1cefa13b19212c391b4eb56cc04313088d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:45:17 -0400 Subject: [PATCH 06/73] fix(smoke): chown the container export back to the caller; test runInContainer (#2625) Addresses Copilot review on #2662: on a rootful Linux daemon the root-run install left a bind-mount tree the host could not remove, and the no-daemon skip had no automated coverage. runInContainer now takes injectable side effects, tested for the skip, exit propagation and cleanup. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/lib/firefox-container.mjs | 84 +++++++++++++++----- scripts/lib/firefox-container.test.mjs | 102 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 21 deletions(-) diff --git a/scripts/lib/firefox-container.mjs b/scripts/lib/firefox-container.mjs index 4731e46b12..9bf99a0189 100644 --- a/scripts/lib/firefox-container.mjs +++ b/scripts/lib/firefox-container.mjs @@ -111,8 +111,17 @@ export function playwrightImage(version) { * incidental. `--ipc=host` is Playwright's own recommendation for Chromium- * family shared memory and harmless for Firefox. `CI` is not forwarded, so the * smokes behave as they do on a developer machine. + * + * The image runs as root, so on a rootful Linux daemon everything the install + * and build write into the bind mount is root-owned and the host cannot remove + * it afterwards. When the caller's `owner` (`uid:gid`) is known, an `EXIT` trap + * hands the tree back before the container exits — on success or failure — so + * the host-side cleanup can delete it. Docker Desktop maps ownership itself, + * where the `chown` is a harmless no-op. */ -export function dockerRunArgs({ exportDir, image, engine }) { +export function dockerRunArgs({ exportDir, image, engine, owner }) { + const run = `npm install --no-audit --no-fund && npm run build && node scripts/run-engine-smokes.mjs ${engine}`; + const script = owner ? `trap 'chown -R ${owner} /src' EXIT; ${run}` : run; return [ "run", "--rm", @@ -126,7 +135,7 @@ export function dockerRunArgs({ exportDir, image, engine }) { image, "bash", "-lc", - `npm install --no-audit --no-fund && npm run build && node scripts/run-engine-smokes.mjs ${engine}`, + script, ]; } @@ -169,15 +178,56 @@ function dockerAvailable() { return probe.status === 0; } +/** The caller's `uid:gid`, or undefined where POSIX ids do not exist (Windows). */ +function hostOwner() { + return typeof process.getuid === "function" && + typeof process.getgid === "function" + ? `${process.getuid()}:${process.getgid()}` + : undefined; +} + +/** + * The side effects `runInContainer` performs, injectable so its branches — + * the no-daemon skip, exit propagation, temp-tree cleanup — are tested + * without Docker. + */ +export const defaultContainerDeps = { + dockerAvailable, + readLockfile: (repoRoot) => + readFileSync(join(repoRoot, "clients/web/package-lock.json"), "utf8"), + makeTempDir: (engine) => + mkdtempSync(join(tmpdir(), `inspector-${engine}-smoke-`)), + exportWorkingTree, + runDocker: (args) => spawnSync("docker", args, { stdio: "inherit" }), + removeDir: (dir) => + rmSync(dir, { recursive: true, force: true, maxRetries: 3 }), + hostOwner, + log: console.log, + warn: console.warn, + error: console.error, +}; + /** * Run the engine pass in the container. Returns the exit status to use: * the container's status, or 0 on a loud skip when Docker is unavailable. */ -export function runInContainer({ repoRoot, engine }) { +export function runInContainer({ repoRoot, engine }, deps = {}) { + const { + dockerAvailable, + readLockfile, + makeTempDir, + exportWorkingTree, + runDocker, + removeDir, + hostOwner, + log, + warn, + error, + } = { ...defaultContainerDeps, ...deps }; const banner = `run-engine-smokes: ${engine} cannot launch natively on this host (${ISSUE})`; if (!dockerAvailable()) { - console.warn( + warn( [ "", `⚠️ ${banner}, and no Docker daemon is reachable.`, @@ -191,38 +241,30 @@ export function runInContainer({ repoRoot, engine }) { } const image = playwrightImage( - pinnedPlaywrightVersion( - readFileSync(join(repoRoot, "clients/web/package-lock.json"), "utf8"), - ), + pinnedPlaywrightVersion(readLockfile(repoRoot)), ); - const exportDir = mkdtempSync(join(tmpdir(), `inspector-${engine}-smoke-`)); + const exportDir = makeTempDir(engine); try { const count = exportWorkingTree(repoRoot, exportDir); - console.log( - `${banner}; running it in ${image} over ${count} exported files`, - ); - const run = spawnSync( - "docker", - dockerRunArgs({ exportDir, image, engine }), - { stdio: "inherit" }, + log(`${banner}; running it in ${image} over ${count} exported files`); + const run = runDocker( + dockerRunArgs({ exportDir, image, engine, owner: hostOwner() }), ); if (run.error) { - console.error(`run-engine-smokes: docker failed: ${run.error.message}`); + error(`run-engine-smokes: docker failed: ${run.error.message}`); return 1; } if (run.status !== 0) { - console.error( - `run-engine-smokes: the ${engine} pass failed inside ${image}`, - ); + error(`run-engine-smokes: the ${engine} pass failed inside ${image}`); } return run.status ?? 1; } finally { // Best effort: the container wrote node_modules and builds here, and a // leftover temp directory is not worth failing a green run over. try { - rmSync(exportDir, { recursive: true, force: true, maxRetries: 3 }); + removeDir(exportDir); } catch (err) { - console.warn( + warn( `run-engine-smokes: could not remove ${exportDir}: ${err instanceof Error ? err.message : String(err)}`, ); } diff --git a/scripts/lib/firefox-container.test.mjs b/scripts/lib/firefox-container.test.mjs index c3b449c21a..1283b022fa 100644 --- a/scripts/lib/firefox-container.test.mjs +++ b/scripts/lib/firefox-container.test.mjs @@ -19,6 +19,7 @@ import { exportablePaths, pinnedPlaywrightVersion, playwrightImage, + runInContainer, shouldUseContainer, } from "./firefox-container.mjs"; @@ -126,6 +127,20 @@ describe("dockerRunArgs", () => { assert.ok(args.includes(`${MODE_ENV}=native`)); }); + it("hands the tree back to the caller on exit when the owner is known", () => { + const owned = dockerRunArgs({ + exportDir: "/tmp/x", + image: "img:tag", + engine: "firefox", + owner: "501:20", + }); + assert.match( + owned.at(-1), + /^trap 'chown -R 501:20 \/src' EXIT; npm install /, + ); + assert.doesNotMatch(args.at(-1), /chown/); + }); + it("installs, builds, then runs the same runner for the same engine", () => { assert.match( args.at(-1), @@ -151,6 +166,93 @@ describe("exportablePaths", () => { }); }); +describe("runInContainer", () => { + const LOCK = JSON.stringify({ + packages: { "node_modules/playwright": { version: "1.62.1" } }, + }); + + /** Fakes for every side effect, recording what was called. */ + function fakes(overrides = {}) { + const calls = { docker: [], removed: [], warned: [], errored: [] }; + const deps = { + dockerAvailable: () => true, + readLockfile: () => LOCK, + makeTempDir: () => "/tmp/export", + exportWorkingTree: () => 3, + runDocker: (args) => { + calls.docker.push(args); + return { status: 0 }; + }, + removeDir: (dir) => calls.removed.push(dir), + hostOwner: () => "501:20", + log: () => {}, + warn: (msg) => calls.warned.push(msg), + error: (msg) => calls.errored.push(msg), + ...overrides, + }; + return { calls, deps }; + } + const run = (deps) => + runInContainer({ repoRoot: "/repo", engine: "firefox" }, deps); + + it("skips loudly and exits 0 when no Docker daemon answers", () => { + const { calls, deps } = fakes({ dockerAvailable: () => false }); + assert.equal(run(deps), 0); + assert.equal(calls.docker.length, 0, "must not try to run docker"); + assert.equal(calls.removed.length, 0, "nothing was created to remove"); + const banner = calls.warned.join("\n"); + assert.match(banner, /SKIPPING the firefox engine pass/); + assert.match(banner, /NOT a pass/); + assert.match(banner, new RegExp(ISSUE)); + }); + + it("runs the pinned image over the export as the caller, then cleans up", () => { + const { calls, deps } = fakes(); + assert.equal(run(deps), 0); + const [args] = calls.docker; + assert.ok(args.includes("mcr.microsoft.com/playwright:v1.62.1-noble")); + assert.ok(args.includes("/tmp/export:/src")); + assert.match(args.at(-1), /chown -R 501:20/); + assert.deepEqual(calls.removed, ["/tmp/export"]); + }); + + it("propagates a failing container's status and still cleans up", () => { + const { calls, deps } = fakes({ runDocker: () => ({ status: 7 }) }); + assert.equal(run(deps), 7); + assert.match(calls.errored.join(), /failed inside/); + assert.deepEqual(calls.removed, ["/tmp/export"]); + }); + + it("fails on a docker spawn error and on a signal-killed container", () => { + const spawnErr = fakes({ + runDocker: () => ({ status: null, error: new Error("ENOENT") }), + }); + assert.equal(run(spawnErr.deps), 1); + assert.match(spawnErr.calls.errored.join(), /docker failed: ENOENT/); + assert.equal(run(fakes({ runDocker: () => ({ status: null }) }).deps), 1); + }); + + it("cleans up even when the export throws", () => { + const { calls, deps } = fakes({ + exportWorkingTree: () => { + throw new Error("git ls-files failed"); + }, + }); + assert.throws(() => run(deps), /git ls-files failed/); + assert.deepEqual(calls.removed, ["/tmp/export"]); + }); + + it("only warns when cleanup fails, keeping the run's status", () => { + const { calls, deps } = fakes({ + removeDir: () => { + throw new Error("EACCES"); + }, + }); + assert.equal(run(deps), 0); + assert.match(calls.warned.join(), /could not remove \/tmp\/export: EACCES/); + }); +}); + it("names the tracking issue", () => { assert.equal(ISSUE, "#2625"); }); From 983345159280c081f673a0cef2f7ae812e5753f2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:56:14 -0400 Subject: [PATCH 07/73] chore(deps): upgrade the MCP TypeScript SDK to 2.3.1 (#2606) Bump the four lockstep-pinned typescript-sdk packages (client, core, server, server-legacy) from 2.2.0 to 2.3.1 in the root manifest and refresh the lockfile. eventsource-parser moves 3.0.6 -> 3.1.1 to meet the client's new >=3.0.8 floor. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 54 +++++++++++++++++++++++------------------------ package.json | 8 +++---- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03a4bdbd1d..b1b238d5fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.2.0", - "@modelcontextprotocol/core": "2.2.0", + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/core": "2.3.1", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/ext-tasks": "^0.2.2", - "@modelcontextprotocol/server": "2.2.0", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -40,7 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@modelcontextprotocol/server-legacy": "2.2.0", + "@modelcontextprotocol/server-legacy": "2.3.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "4.1.11", @@ -629,15 +629,15 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", - "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.2.0", + "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", + "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" @@ -647,10 +647,10 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", - "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -709,12 +709,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.2.0.tgz", - "integrity": "sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.2.0", + "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" }, "engines": { @@ -722,14 +722,14 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.2.0.tgz", - "integrity": "sha512-hmU22Em1n9dm24xC7pt8okXKJpmpjCxm3GGQo+l99x6T+SPOX3v+7+WUEpp/ZZVLLIzCELVJtyRM8U3FxvMZpA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.3.1.tgz", + "integrity": "sha512-+cuB+hl7s8DiFXIt13le3cD2wZPOeDYwlycAkRMPljqqNbOLekGRiyslx8WXfqaXSCveS1eDD8/B8rmxz9BZFQ==", "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.", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.2.0", + "@modelcontextprotocol/core": "2.3.1", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", @@ -2728,9 +2728,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 901ecff034..08c6662153 100644 --- a/package.json +++ b/package.json @@ -123,11 +123,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.2.0", - "@modelcontextprotocol/core": "2.2.0", + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/core": "2.3.1", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/ext-tasks": "^0.2.2", - "@modelcontextprotocol/server": "2.2.0", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -150,7 +150,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@modelcontextprotocol/server-legacy": "2.2.0", + "@modelcontextprotocol/server-legacy": "2.3.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "4.1.11", From fc65347d81233ec82c8425cc87046777812025c6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:56:57 -0400 Subject: [PATCH 08/73] fix(gate-lease): refuse a lease dir this user does not own (#2624) The POSIX default is a predictable name in world-writable /tmp, and a recursive mkdir adopts whatever is already there. Require a real directory (not a symlink) owned by this uid and tighten it to 0700; otherwise take the existing unleased-with-a-warning path. Mirrors mcpdo's assertTrustedPrivateRoot. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/gate-lease.mjs | 49 ++++++++++++++++++++++++++--- scripts/gate-lease.test.mjs | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/scripts/gate-lease.mjs b/scripts/gate-lease.mjs index 5f45709c2e..9e8e22c8fc 100644 --- a/scripts/gate-lease.mjs +++ b/scripts/gate-lease.mjs @@ -82,7 +82,9 @@ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import nodeFs, { + chmodSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -156,9 +158,10 @@ export const MAX_WAIT_MS = 45 * 60_000; * - **POSIX:** `/tmp/mcp-inspector-gate-lease-`. `/tmp` is the one * temporary directory every POSIX system has at a fixed path, and the uid * suffix keeps two users of a shared box from contending for one directory - * neither can remove. A directory another user planted there that this one - * cannot write degrades to an unleased run with a warning, like any other - * unusable lease directory. + * neither can remove. Because the name is predictable, a directory or + * symlink another user planted there first is refused rather than adopted + * ({@link assertTrustedLeaseDir}) and degrades to an unleased run with a + * warning, like any other unusable lease directory. * - **Windows:** under the account's profile directory, read from the account * (`os.userInfo()`) rather than from `%USERPROFILE%`, `%TEMP%` or `%TMP%`. * Only if that lookup fails does it fall back to `os.tmpdir()`. @@ -193,6 +196,43 @@ export function leaseDir( return path.win32.join(base, "mcp-inspector-gate-lease"); } +/** + * Refuse a lease directory this user does not control, and make it private. + * + * The POSIX default is a predictable name in world-writable `/tmp`, and + * `mkdirSync(..., { recursive: true })` succeeds silently over an entry that + * is already there. Another local user who created that path first — as a + * directory they own, or as a symlink — would otherwise be adopted: they + * could remove the lock out from under a running gate (two gates overlap + * again, the thing this exists to prevent), and the holder record would be + * written wherever the symlink points. So the directory must be a real + * directory, not a symlink, owned by this uid; it is tightened to `0700` + * when it is not already. Throws otherwise, which the caller turns into the + * same unleased-with-a-warning run as any unusable lease directory. The same + * check guards mcpdo's `/tmp` root (`assertTrustedPrivateRoot` in + * `clients/mcpdo/src/daemon/paths.ts`). A no-op on Windows, which has no uid + * and no POSIX modes, and whose default lives under the account's profile. + * + * @param {string} dir + * @param {{ getuid?: () => number }} [host] injectable: the tests cannot + * create a directory owned by a second user without root + */ +export function assertTrustedLeaseDir(dir, { getuid = process.getuid } = {}) { + if (typeof getuid !== "function") return; + const st = lstatSync(dir); + if (!st.isDirectory()) { + throw new Error( + `refusing ${dir}: not a directory (a file or symlink is in its place)`, + ); + } + if (st.uid !== getuid()) { + throw new Error( + `refusing ${dir}: owned by uid ${st.uid}, not this user (uid ${getuid()})`, + ); + } + if ((st.mode & 0o077) !== 0) chmodSync(dir, 0o700); +} + /** * The lease target: `proper-lockfile` locks `.lock` beside it and * never opens the target itself, so the target doubles as the holder record @@ -678,7 +718,8 @@ export async function runUnderLease({ } else { let usable = true; try { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + assertTrustedLeaseDir(dir); } catch (err) { usable = false; log( diff --git a/scripts/gate-lease.test.mjs b/scripts/gate-lease.test.mjs index 5d5b89c090..566b62a6e4 100644 --- a/scripts/gate-lease.test.mjs +++ b/scripts/gate-lease.test.mjs @@ -10,12 +10,15 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import nodeFs from "node:fs"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; @@ -25,6 +28,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { + assertTrustedLeaseDir, DIR_ENV, SKIP_ENV, STALE_MS, @@ -480,6 +484,65 @@ test("a lease directory that cannot exist degrades to an unleased run", async () ); }); +test( + "a symlink planted at the lease directory is refused, not followed", + { + skip: + process.platform === "win32" && "no POSIX ownership checks on Windows", + }, + async () => { + const parent = freshDir(); + const target = join(parent, "elsewhere"); + mkdirSync(target); + const dir = join(parent, "lease"); + symlinkSync(target, dir); + const { lines, log } = collectLog(); + assert.equal(await runNode("", { dir, log }), 0); + assert.match( + lines.join("\n"), + /not a directory.*running without the lease/, + ); + assert.deepEqual(readdirSync(target), [], "nothing was written through it"); + }, +); + +test( + "assertTrustedLeaseDir: a directory owned by another uid is refused", + { + skip: + process.platform === "win32" && "no POSIX ownership checks on Windows", + }, + () => { + const dir = freshDir(); + const other = process.getuid() + 1; + assert.throws( + () => assertTrustedLeaseDir(dir, { getuid: () => other }), + new RegExp( + `owned by uid ${process.getuid()}, not this user \\(uid ${other}\\)`, + ), + ); + }, +); + +test( + "assertTrustedLeaseDir: an open mode is tightened to 0700", + { + skip: process.platform === "win32" && "no POSIX modes on Windows", + }, + () => { + const dir = freshDir(); + chmodSync(dir, 0o777); + assertTrustedLeaseDir(dir); + assert.equal(statSync(dir).mode & 0o777, 0o700); + }, +); + +test("assertTrustedLeaseDir: a no-op without getuid (Windows)", () => { + assert.doesNotThrow(() => + assertTrustedLeaseDir(join(freshDir(), "missing"), { getuid: null }), + ); +}); + /** The pid of a process that has already exited, for a provably dead ticket. */ function deadPid() { const { pid } = spawnSync(process.execPath, ["-e", ""]); From 68fd67e7949b30a6bc6cada17e844f380ba6dbd2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:08:37 -0400 Subject: [PATCH 09/73] fix(smoke): reject an unrecognized INSPECTOR_FIREFOX_SMOKE (#2625) Addresses Copilot review round 3 on #2662: a typo'd override fell through to host routing and could report green for a path that never ran. It now fails closed like SMOKE_BROWSER, and a forced container run no longer claims the host cannot launch Firefox. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- docs/quality-gate.md | 2 +- scripts/lib/firefox-container.mjs | 26 ++++++++++++++--- scripts/lib/firefox-container.test.mjs | 40 ++++++++++++++++++++++---- scripts/run-engine-smokes.mjs | 15 +++++++--- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 8260b2a4d7..d49f15eca0 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -81,7 +81,7 @@ Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run loc Putting it in `npm run local:gate` instead keeps the check where it is worth most — in front of a human about to push a change they can still reason about — and pays for it once per push rather than once per CI run. If a cross-engine regression ever reaches `v2/main` because someone skipped the gate, that is the evidence for restoring the CI job; it is a one-job diff. -**On macOS 27 the Firefox pass runs in a container ([#2625](https://github.com/modelcontextprotocol/inspector/issues/2625)).** Playwright's Firefox does not launch on Darwin 27 — two builds (rev 1538 from Playwright 1.62.1, rev 1543 from 1.63.0), headless and headed — so on those hosts `smoke:web:firefox` runs the same three smokes inside `mcr.microsoft.com/playwright:v-noble`, with the tag read from `clients/web/package-lock.json`. It runs over a temporary export of the working tree (tracked and untracked-not-ignored files, uncommitted edits included), because the host `node_modules` carry macOS native binaries; the container installs and builds from scratch, so expect a few extra minutes and a one-time image pull. **With no reachable Docker daemon it skips loudly and exits 0**, printing that the skip is not a pass, so `local:storybook` behind it still runs — start Docker and re-run `npm run smoke:web:firefox` before pushing. `INSPECTOR_FIREFOX_SMOKE=native` forces a native launch (how to try a new Playwright build before retiring the container path) and `=container` forces the container on any host. Earlier macOS, Linux and Windows hosts launch natively as before. The routing lives in `scripts/lib/firefox-container.mjs`; delete it once a Playwright Firefox launches on macOS 27. +**On macOS 27 the Firefox pass runs in a container ([#2625](https://github.com/modelcontextprotocol/inspector/issues/2625)).** Playwright's Firefox does not launch on Darwin 27 — two builds (rev 1538 from Playwright 1.62.1, rev 1543 from 1.63.0), headless and headed — so on those hosts `smoke:web:firefox` runs the same three smokes inside `mcr.microsoft.com/playwright:v-noble`, with the tag read from `clients/web/package-lock.json`. It runs over a temporary export of the working tree (tracked and untracked-not-ignored files, uncommitted edits included), because the host `node_modules` carry macOS native binaries; the container installs and builds from scratch, so expect a few extra minutes and a one-time image pull. **With no reachable Docker daemon it skips loudly and exits 0**, printing that the skip is not a pass, so `local:storybook` behind it still runs — start Docker and re-run `npm run smoke:web:firefox` before pushing. `INSPECTOR_FIREFOX_SMOKE=native` forces a native launch (how to try a new Playwright build before retiring the container path) and `=container` forces the container on any host; any other value is an error rather than a fallback, as with `SMOKE_BROWSER`. Earlier macOS, Linux and Windows hosts launch natively as before. The routing lives in `scripts/lib/firefox-container.mjs`; delete it once a Playwright Firefox launches on macOS 27. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/scripts/lib/firefox-container.mjs b/scripts/lib/firefox-container.mjs index 9bf99a0189..37c6dc12fd 100644 --- a/scripts/lib/firefox-container.mjs +++ b/scripts/lib/firefox-container.mjs @@ -54,9 +54,14 @@ export const ISSUE = "#2625"; /** * Override for the routing decision: `container` forces the container on any - * host, `native` forces a native launch on any host. Anything else (or unset) + * host, `native` forces a native launch on any host, and unset or empty * leaves the decision to the host check. `native` is how to try a new * Playwright build on macOS 27 before retiring this module. + * + * Any other value is an error, not a fallthrough — the same fail-closed rule + * as `SMOKE_BROWSER` (`headless-browser.mjs`). A typo'd `contaner` on Linux + * would otherwise run natively and report green for a container path that + * never ran, and a typo'd `native` on macOS 27 would defeat the trial. */ export const MODE_ENV = "INSPECTOR_FIREFOX_SMOKE"; @@ -71,8 +76,13 @@ const FIRST_UNSUPPORTED_DARWIN = 27; * be pure cost. */ export function shouldUseContainer({ engine, platform, release, env }) { - if (engine !== "firefox") return false; const mode = env[MODE_ENV]; + if (mode && mode !== "container" && mode !== "native") { + throw new Error( + `unsupported ${MODE_ENV}="${mode}" — expected "native", "container", or unset`, + ); + } + if (engine !== "firefox") return false; if (mode === "container") return true; if (mode === "native") return false; if (platform !== "darwin") return false; @@ -211,7 +221,10 @@ export const defaultContainerDeps = { * Run the engine pass in the container. Returns the exit status to use: * the container's status, or 0 on a loud skip when Docker is unavailable. */ -export function runInContainer({ repoRoot, engine }, deps = {}) { +export function runInContainer( + { repoRoot, engine, env = process.env }, + deps = {}, +) { const { dockerAvailable, readLockfile, @@ -224,7 +237,12 @@ export function runInContainer({ repoRoot, engine }, deps = {}) { warn, error, } = { ...defaultContainerDeps, ...deps }; - const banner = `run-engine-smokes: ${engine} cannot launch natively on this host (${ISSUE})`; + // A forced run says so; claiming the host cannot launch the engine would be + // false on, say, a Linux host using the override. + const banner = + env[MODE_ENV] === "container" + ? `run-engine-smokes: ${engine} routed to the container by ${MODE_ENV}=container` + : `run-engine-smokes: ${engine} cannot launch natively on this host (${ISSUE})`; if (!dockerAvailable()) { warn( diff --git a/scripts/lib/firefox-container.test.mjs b/scripts/lib/firefox-container.test.mjs index 1283b022fa..ed2115a862 100644 --- a/scripts/lib/firefox-container.test.mjs +++ b/scripts/lib/firefox-container.test.mjs @@ -67,11 +67,24 @@ describe("shouldUseContainer", () => { ), true, ); - // An unrecognized value falls through to the host check. - assert.equal( - shouldUseContainer(host({ env: { [MODE_ENV]: "maybe" } })), - true, - ); + // Empty is treated as unset. + assert.equal(shouldUseContainer(host({ env: { [MODE_ENV]: "" } })), true); + }); + + it("rejects an unrecognized override rather than falling through", () => { + for (const engine of ["firefox", "chromium"]) { + assert.throws( + () => + shouldUseContainer( + host({ + engine, + platform: "linux", + env: { [MODE_ENV]: "contaner" }, + }), + ), + new RegExp(`unsupported ${MODE_ENV}="contaner"`), + ); + } }); }); @@ -193,7 +206,7 @@ describe("runInContainer", () => { return { calls, deps }; } const run = (deps) => - runInContainer({ repoRoot: "/repo", engine: "firefox" }, deps); + runInContainer({ repoRoot: "/repo", engine: "firefox", env: {} }, deps); it("skips loudly and exits 0 when no Docker daemon answers", () => { const { calls, deps } = fakes({ dockerAvailable: () => false }); @@ -216,6 +229,21 @@ describe("runInContainer", () => { assert.deepEqual(calls.removed, ["/tmp/export"]); }); + it("says a forced run was forced, not that the host cannot launch", () => { + const lines = []; + const { deps } = fakes({ log: (msg) => lines.push(msg) }); + runInContainer( + { + repoRoot: "/repo", + engine: "firefox", + env: { [MODE_ENV]: "container" }, + }, + deps, + ); + assert.match(lines.join(), new RegExp(`${MODE_ENV}=container`)); + assert.doesNotMatch(lines.join(), /cannot launch natively/); + }); + it("propagates a failing container's status and still cleans up", () => { const { calls, deps } = fakes({ runDocker: () => ({ status: 7 }) }); assert.equal(run(deps), 7); diff --git a/scripts/run-engine-smokes.mjs b/scripts/run-engine-smokes.mjs index 549ec75a98..16b071dbb6 100644 --- a/scripts/run-engine-smokes.mjs +++ b/scripts/run-engine-smokes.mjs @@ -60,14 +60,21 @@ function main() { process.exit(1); } - if ( - shouldUseContainer({ + let useContainer; + try { + useContainer = shouldUseContainer({ engine: browserName, platform: platform(), release: release(), env: process.env, - }) - ) { + }); + } catch (err) { + console.error( + `run-engine-smokes: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + if (useContainer) { process.exit( runInContainer({ repoRoot: join(scriptDir, ".."), engine: browserName }), ); From fe0a10616187b2c0a0c6becae4629ebb7bb8660c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:49:07 -0400 Subject: [PATCH 10/73] fix(auth): carry stripped-namespace secrets over before purging (#2630) A 2.9.x save strips the secretsNamespace stamp and keeps a confidential DCR client's client_id, but can only see the legacy ids, which adoption already emptied. The next 2.10 save re-adopted under a fresh namespace and purged the old one first, destroying the only copy of the client secret, so the next token exchange failed until re-authorization. Reads of an unstamped file and adoption now fall back, newest first, to the namespaces its ledger records for any field the legacy ids lack, and adoption purges the superseded namespaces only after the new stamp has committed with those values copied under it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../core/auth/oauth-namespace-ledger.test.ts | 24 +++ .../auth/node/oauth-secrets-namespace.test.ts | 182 +++++++++++++++++ core/auth/node/oauth-namespace-ledger.ts | 30 +++ core/auth/node/oauth-persist-file.ts | 190 +++++++++++++----- docs/secret-storage.md | 2 +- 5 files changed, 376 insertions(+), 52 deletions(-) diff --git a/clients/web/src/test/core/auth/oauth-namespace-ledger.test.ts b/clients/web/src/test/core/auth/oauth-namespace-ledger.test.ts index ada92ca161..fcb44702cd 100644 --- a/clients/web/src/test/core/auth/oauth-namespace-ledger.test.ts +++ b/clients/web/src/test/core/auth/oauth-namespace-ledger.test.ts @@ -32,6 +32,7 @@ import { writeStoreFile } from "@inspector/core/storage/store-io.js"; import { namespaceLedgerPath, purgeSupersededNamespaces, + recordedNamespaces, recordNamespaceKeys, resetNamespaceLedgerWarnings, secretStoreLocation, @@ -254,6 +255,29 @@ describe("secretStoreLocation", () => { }); }); +describe("recordedNamespaces (#2630)", () => { + it("lists every recorded namespace, newest first", async () => { + await recordNamespaceKeys(stateFile, store, NS1, [SERVER], []); + await recordNamespaceKeys(stateFile, store, NS2, [SERVER], []); + // Re-recording an older namespace's keys does not move it. + await recordNamespaceKeys(stateFile, store, NS1, [ISSUER], []); + expect(await recordedNamespaces(stateFile)).toEqual([NS2, NS1]); + }); + + it("is empty without a ledger", async () => { + expect(await recordedNamespaces(stateFile)).toEqual([]); + }); + + it("warns and reads as empty when the ledger cannot be read", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + mkdirSync(ledgerFile); + expect(await recordedNamespaces(stateFile)).toEqual([]); + expect(warn.mock.calls[0][0]).toContain( + "Could not read the OAuth secrets-namespace ledger", + ); + }); +}); + describe("purgeSupersededNamespaces", () => { async function seed(namespace: string): Promise { await recordNamespaceKeys(stateFile, store, namespace, [SERVER], [ISSUER]); diff --git a/clients/web/src/test/integration/auth/node/oauth-secrets-namespace.test.ts b/clients/web/src/test/integration/auth/node/oauth-secrets-namespace.test.ts index 3dda230dbe..3969a110f7 100644 --- a/clients/web/src/test/integration/auth/node/oauth-secrets-namespace.test.ts +++ b/clients/web/src/test/integration/auth/node/oauth-secrets-namespace.test.ts @@ -82,6 +82,7 @@ import { } from "@inspector/core/auth/node/secret-store-selection.js"; import { namespaceLedgerPath, + recordNamespaceKeys, resetNamespaceLedgerWarnings, } from "@inspector/core/auth/node/oauth-namespace-ledger.js"; import { @@ -91,6 +92,7 @@ import { isValidSecretsNamespace, LEGACY_TOKENS_FIELD, IDP_SESSION_FIELD, + issuerClientSecretField, resetPersistTokensPolicyWarnings, } from "@inspector/core/auth/node/oauth-secrets.js"; import { @@ -845,3 +847,183 @@ describe("namespace ledger: superseded namespaces are purged (#2560)", () => { expect(await store.get(idB, LEGACY_TOKENS_FIELD)).not.toBeNull(); }); }); + +describe("a stripped stamp keeps what only its namespace holds (#2630)", () => { + const OTHER = "https://other.example/mcp"; + const SECRET_FIELD = issuerClientSecretField(ISSUER); + + /** A server whose dynamically registered client is confidential. */ + function confidentialClient(secret: string): OAuthPersistSnapshot { + return { + servers: { + [SERVER]: { + activeIssuer: ISSUER, + byIssuer: { + [ISSUER]: { + clientInformation: { + client_id: "dcr-client", + client_secret: secret, + }, + clientRegistrationKind: "dcr", + tokens: tokensFor("one"), + }, + }, + }, + }, + idpSessions: { [ISSUER]: { idToken: "id-1", refreshToken: "rt-1" } }, + }; + } + + /** What a ≤ 2.9.x save leaves: the same residue, no stamp. */ + async function stripStamp(filePath: string): Promise { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Record< + string, + unknown + >; + delete parsed[SECRETS_NAMESPACE_KEY]; + await writeStoreFile(filePath, JSON.stringify(parsed)); + await flushStoreFileWrites(filePath); + } + + async function loginThenStrip(store: SecretStore): Promise { + await writeOAuthSections( + fileA, + confidentialClient("s3cret"), + undefined, + store, + ); + await flushStoreFileWrites(fileA); + const ns1 = namespaceOf(fileA); + await stripStamp(fileA); + return ns1; + } + + function clientOf(snapshot: OAuthPersistSnapshot | null) { + return snapshot?.servers[SERVER]?.byIssuer?.[ISSUER]?.clientInformation; + } + + it("carries the client secret into the new namespace before purging the old one (the issue's repro)", async () => { + const store = new InMemorySecretStore(); + const ns1 = await loginThenStrip(store); + + // Any save re-adopts; this one touches an unrelated server only. + await writeOAuthSections( + fileA, + { servers: { [OTHER]: { scope: "x" } }, idpSessions: {} }, + { servers: [OTHER] }, + store, + ); + await flushStoreFileWrites(fileA); + const ns2 = namespaceOf(fileA); + expect(ns2).not.toBe(ns1); + + expect( + await store.get(oauthSecretServerId(SERVER, ns2), SECRET_FIELD), + ).toBe("s3cret"); + expect( + await store.get(oauthSecretServerId(SERVER, ns1), SECRET_FIELD), + ).toBeNull(); + const read = await readOAuthStore(fileA, store); + expect(clientOf(read)?.client_secret).toBe("s3cret"); + expect(read?.servers[SERVER]?.byIssuer?.[ISSUER]?.tokens).toEqual( + tokensFor("one"), + ); + expect(read?.idpSessions[ISSUER]).toMatchObject({ + idToken: "id-1", + refreshToken: "rt-1", + }); + }); + + it("an unstamped file's read already sees the secret, so a save of that state keeps it", async () => { + const store = new InMemorySecretStore(); + await loginThenStrip(store); + + // A 2.10 process loads the stripped file, then saves the same server. + const loaded = await readOAuthStore(fileA, store); + expect(clientOf(loaded)?.client_secret).toBe("s3cret"); + await writeOAuthSections(fileA, loaded!, { servers: [SERVER] }, store); + await flushStoreFileWrites(fileA); + + const read = await readOAuthStore(fileA, store); + expect(clientOf(read)?.client_secret).toBe("s3cret"); + }); + + it("a value the older Inspector wrote to the legacy id since the strip wins", async () => { + const store = new InMemorySecretStore(); + await loginThenStrip(store); + // The older Inspector re-registered and stored its own secret. + await store.set(oauthSecretServerId(SERVER), SECRET_FIELD, "from-2.9"); + + expect(clientOf(await readOAuthStore(fileA, store))?.client_secret).toBe( + "from-2.9", + ); + await writeOAuthSections( + fileA, + { servers: { [OTHER]: { scope: "x" } }, idpSessions: {} }, + { servers: [OTHER] }, + store, + ); + await flushStoreFileWrites(fileA); + expect( + await store.get( + oauthSecretServerId(SERVER, namespaceOf(fileA)), + SECRET_FIELD, + ), + ).toBe("from-2.9"); + }); + + it("prefers the newest recorded namespace, and fills from older ones only what it lacks", async () => { + const store = new InMemorySecretStore(); + const OLD = "11111111-1111-4111-8111-111111111111"; + const NEW = "22222222-2222-4222-8222-222222222222"; + await recordNamespaceKeys(fileA, store, OLD, [SERVER], []); + await recordNamespaceKeys(fileA, store, NEW, [SERVER], []); + await store.set(oauthSecretServerId(SERVER, OLD), SECRET_FIELD, "old"); + await store.set( + oauthSecretServerId(SERVER, OLD), + LEGACY_TOKENS_FIELD, + JSON.stringify(tokensFor("old")), + ); + await store.set(oauthSecretServerId(SERVER, NEW), SECRET_FIELD, "new"); + await writeStoreFile( + fileA, + JSON.stringify({ + servers: { + [SERVER]: { + byIssuer: { [ISSUER]: { clientInformation: { client_id: "c" } } }, + }, + }, + idpSessions: {}, + }), + ); + await flushStoreFileWrites(fileA); + + const read = await readOAuthStore(fileA, store); + expect(clientOf(read)?.client_secret).toBe("new"); + expect(read?.servers[SERVER]?.tokens).toEqual(tokensFor("old")); + }); + + it("a stamped file never falls back to a namespace it no longer carries", async () => { + const store = new InMemorySecretStore(); + const STALE = "33333333-3333-4333-8333-333333333333"; + await recordNamespaceKeys(fileA, store, STALE, [SERVER], []); + await store.set(oauthSecretServerId(SERVER, STALE), SECRET_FIELD, "stale"); + await writeStoreFile( + fileA, + JSON.stringify({ + [SECRETS_NAMESPACE_KEY]: "44444444-4444-4444-8444-444444444444", + servers: { + [SERVER]: { + byIssuer: { [ISSUER]: { clientInformation: { client_id: "c" } } }, + }, + }, + idpSessions: {}, + }), + ); + await flushStoreFileWrites(fileA); + + expect( + clientOf(await readOAuthStore(fileA, store))?.client_secret, + ).toBeUndefined(); + }); +}); diff --git a/core/auth/node/oauth-namespace-ledger.ts b/core/auth/node/oauth-namespace-ledger.ts index 3f964db13e..c276e010f8 100644 --- a/core/auth/node/oauth-namespace-ledger.ts +++ b/core/auth/node/oauth-namespace-ledger.ts @@ -255,6 +255,36 @@ export async function recordNamespaceKeys( } } +/** + * Every namespace the ledger records, newest first (#2630). A namespace is + * appended when it is first recorded and JSON keeps that order, so the last + * one recorded is the one the file most recently carried. + * + * This is how an unstamped file's entries reach the secrets their old + * namespace still holds. A ≤ 2.9.x save strips the stamp but keeps the + * entries, and reads secrets only from the legacy shared ids, which + * adoption already emptied, so a confidential DCR client's `client_secret` + * survives only under the stripped namespace. Reads and adoption fall back + * to these namespaces for any field the legacy ids lack, which carries the + * secret into the next namespace before the purge removes the old one. + * + * Best-effort like the rest of the ledger: an unreadable ledger warns and + * reads as empty, which is the same outcome as not having a ledger. + */ +export async function recordedNamespaces( + stateFilePath: string, +): Promise { + try { + const ledger = parseLedger( + await readStoreFile(namespaceLedgerPath(stateFilePath)), + ); + return [...ledger.keys()].reverse(); + } catch (error) { + warnLedgerFailure("read", error); + return []; + } +} + /** * Purge, from `secretStore`, every recorded namespace other than `current` * — the namespace the state file actually carries, or `undefined` when it diff --git a/core/auth/node/oauth-persist-file.ts b/core/auth/node/oauth-persist-file.ts index 35bf7da60b..028b5987fe 100644 --- a/core/auth/node/oauth-persist-file.ts +++ b/core/auth/node/oauth-persist-file.ts @@ -32,6 +32,9 @@ * the file has used, so entries under one the file no longer carries — * its stamp stripped by an older Inspector's save — are purged on the * next locked adoption or removal instead of orphaned in the store. + * Until then, an unstamped file's reads and adoption fall back to those + * namespaces for any secret the legacy ids lack (#2630), so what the + * older Inspector could not see is carried over rather than purged. */ import { @@ -67,6 +70,7 @@ import { import { defaultSecretStore } from "./secret-store-selection.js"; import { purgeSupersededNamespaces, + recordedNamespaces, recordNamespaceKeys, } from "./oauth-namespace-ledger.js"; import { MAX_WRITE_ATTEMPTS } from "./file-secret-store.js"; @@ -354,14 +358,21 @@ function warnNamespaceCleanupFailure(error: unknown): void { * allowed unlocked: there is nothing to move, and a concurrent * mint converges via the namespace re-key in `writeOAuthSections`. * - * Under the lock, adoption first purges every namespace the ledger records - * (#2560): reaching this point means the file carries none, so each + * Under the lock, adoption also purges every other namespace the ledger + * records (#2560): reaching this point means the file carries none, so each * recorded one is superseded — most often a stamp an older Inspector's save - * stripped, whose entries nothing else would ever find. A stamped file's - * locked saves purge every recorded namespace but its own, which is how a - * purge that failed here is retried once the store recovers. Unlocked, the purge - * is skipped: a concurrent adopter's namespace can be recorded before it is - * stamped, and purging it would delete live credentials. + * stripped, whose entries nothing else would ever find. But the stripped + * namespace can hold the only copy of a secret the file still indexes (a + * ≤ 2.9.x Inspector reads only the legacy ids, so it saves a confidential + * DCR client's `client_id` with no secret anywhere it can see), so the copy + * step reads those namespaces too, newest first, for every field the legacy + * ids lack, and the purge runs only after the stamp commits (#2630). A file + * with no entries has nothing to carry and purges straight away. A stamped + * file's locked saves purge every recorded namespace but its own, which is + * how a purge that failed here is retried once the store recovers. + * Unlocked, the purge is skipped: a concurrent adopter's namespace can be + * recorded before it is stamped, and purging it would delete live + * credentials. */ async function adoptSecretsNamespace( filePath: string, @@ -382,28 +393,21 @@ async function adoptSecretsNamespace( if (raw !== null && snapshot === null) { throw new OAuthStateFileUnrecognizedError(filePath, "save"); } - if (locked) { - await purgeSupersededNamespaces(filePath, secretStore, undefined); - } const namespace = newSecretsNamespace(); - if (snapshot === null) return namespace; - - const moves = [ - ...Object.entries(snapshot.servers).map(([url, state]) => ({ - legacyId: oauthSecretServerId(url), - scopedId: oauthSecretServerId(url, namespace), - fields: serverSecretFields(state), - })), - ...Object.keys(snapshot.idpSessions).map((issuer) => ({ - legacyId: oauthIdpSecretServerId(issuer), - scopedId: oauthIdpSecretServerId(issuer, namespace), - fields: [IDP_SESSION_FIELD], - })), - ]; // A recognized but entry-less legacy file indexes no store ids, so no // destructive race exists — it mints like a fresh file, locked or not - // (the save that follows writes the namespace to disk). - if (moves.length === 0) return namespace; + // (the save that follows writes the namespace to disk). With no entries + // left to carry anything over to, every recorded namespace can go now. + if ( + snapshot === null || + (Object.keys(snapshot.servers).length === 0 && + Object.keys(snapshot.idpSessions).length === 0) + ) { + if (locked) { + await purgeSupersededNamespaces(filePath, secretStore, undefined); + } + return namespace; + } if (!locked) { throw new SecretStoreUnavailableError( `Could not save OAuth state: ${filePath} predates per-state-file secret namespaces, and migrating its secret-store entries needs the file lock, which is unavailable here (see the lock warning above). Migrating without it could lose credentials if two processes migrate at once. Nothing was changed; make the lock directory writable and retry — or, if you cannot, clear this file's stored OAuth state and re-authorize (clearing does not migrate, and the fresh state file mints its namespace without the lock).`, @@ -424,10 +428,27 @@ async function adoptSecretsNamespace( // under it — which makes "restore" simply "delete what we copied". const touched: SecretFieldSnapshot[] = []; try { - for (const { legacyId, scopedId, fields } of moves) { - for (const field of fields) { - const value = await secretStoreGetStrict(secretStore, legacyId, field); - if (value === null) continue; + // The legacy ids first, then the namespaces this file used to carry + // for whatever the legacy ids lack (#2630) — a stripped stamp leaves a + // confidential client's secret only there. + const values = await readEntrySecrets( + snapshot, + secretStore, + undefined, + await recordedNamespaces(filePath), + ); + const copies = [ + ...[...values.servers].map(([url, secrets]) => ({ + scopedId: oauthSecretServerId(url, namespace), + secrets, + })), + ...[...values.idpSessions].map(([issuer, secrets]) => ({ + scopedId: oauthIdpSecretServerId(issuer, namespace), + secrets, + })), + ]; + for (const { scopedId, secrets } of copies) { + for (const [field, value] of Object.entries(secrets)) { touched.push({ serverId: scopedId, field, value: null }); await secretStore.set(scopedId, field, value); } @@ -437,9 +458,19 @@ async function adoptSecretsNamespace( await restoreSecretFields(secretStore, touched, warnRestoreFailure); throw error; } + // Only now, with everything the file still indexes copied under the new + // stamp, is every other recorded namespace safe to purge: purging first + // would delete the only copy of what the copies above carried over. + await purgeSupersededNamespaces(filePath, secretStore, namespace); + const legacyIds = [ + ...Object.keys(snapshot.servers).map((url) => oauthSecretServerId(url)), + ...Object.keys(snapshot.idpSessions).map((issuer) => + oauthIdpSecretServerId(issuer), + ), + ]; // Per-id catch: one failed purge must not abandon the remaining legacy // ids — each gets its own best-effort attempt. - for (const { legacyId } of moves) { + for (const legacyId of legacyIds) { try { await secretStore.deleteAllForServer(legacyId); } catch (error) { @@ -1048,39 +1079,90 @@ function secretRequestsFor( return requests; } +/** The secret values each of a snapshot's entries resolves to. */ +interface EntrySecrets { + servers: Map; + idpSessions: Map; +} + +/** Copy into `target` every field it does not already hold. */ +function fillMissingSecrets( + target: OAuthSecretValues, + source: OAuthSecretValues | undefined, +): void { + for (const [field, value] of Object.entries(source ?? {})) { + if (!Object.hasOwn(target, field)) setOwnEntry(target, field, value); + } +} + +/** + * Read every secret the snapshot's entries could hold, from the ids + * `namespace` gives them, then from each of `fallbacks` in order for the + * fields still missing (#2630). `fallbacks` is non-empty only for an + * unstamped file whose ledger records namespaces it used to carry (see + * `recordedNamespaces`): the ids the file names come first, so anything an + * older Inspector wrote there since the strip wins, and the stripped + * namespace supplies only what it alone still holds. + * + * Strict: this read hydrates the memory state that later sectioned writes + * diff against, so a tolerant read during a store outage would present + * every credential as absent — and the next save would *delete* them from + * the store. An unreadable store must fail the read (5xx / failed load), + * not masquerade as empty. + */ +async function readEntrySecrets( + snapshot: OAuthPersistSnapshot, + secretStore: SecretStore, + namespace: string | undefined, + fallbacks: string[], +): Promise { + const servers = new Map(); + const idpSessions = new Map(); + for (const url of Object.keys(snapshot.servers)) servers.set(url, {}); + for (const issuer of Object.keys(snapshot.idpSessions)) { + idpSessions.set(issuer, {}); + } + for (const source of [namespace, ...fallbacks]) { + const requests = secretRequestsFor(snapshot, source); + if (requests.length === 0) break; + const values = await secretStoreGetManyStrict(secretStore, requests); + for (const [url, target] of servers) { + fillMissingSecrets(target, values[oauthSecretServerId(url, source)]); + } + for (const [issuer, target] of idpSessions) { + fillMissingSecrets( + target, + values[oauthIdpSecretServerId(issuer, source)], + ); + } + } + return { servers, idpSessions }; +} + /** Rejoin a residue snapshot with the store's values (store wins). */ async function joinSnapshot( snapshot: OAuthPersistSnapshot, secretStore: SecretStore, namespace: string | undefined, + fallbacks: string[], ): Promise { - const requests = secretRequestsFor(snapshot, namespace); - // Strict: this read hydrates the memory state that later sectioned writes - // diff against, so a tolerant read during a store outage would present - // every credential as absent — and the next save would *delete* them from - // the store. An unreadable store must fail the read (5xx / failed load), - // not masquerade as empty. - const values = - requests.length > 0 - ? await secretStoreGetManyStrict(secretStore, requests) - : {}; + const values = await readEntrySecrets( + snapshot, + secretStore, + namespace, + fallbacks, + ); return { servers: Object.fromEntries( Object.entries(snapshot.servers).map(([url, state]) => [ url, - joinServerOAuthState( - state, - values[oauthSecretServerId(url, namespace)] ?? {}, - ), + joinServerOAuthState(state, values.servers.get(url) ?? {}), ]), ), idpSessions: Object.fromEntries( Object.entries(snapshot.idpSessions).map(([issuer, session]) => [ issuer, - joinIdpSession( - session, - values[oauthIdpSecretServerId(issuer, namespace)] ?? {}, - ), + joinIdpSession(session, values.idpSessions.get(issuer) ?? {}), ]), ), }; @@ -1233,8 +1315,14 @@ export async function readOAuthStore( } // Reads never adopt: a legacy file's entries stay at their legacy ids - // until a write mints the namespace and moves them (#2549). - return joinSnapshot(snapshot, secretStore, parseSecretsNamespace(raw)); + // until a write mints the namespace and moves them (#2549). An + // unstamped file also falls back to the namespaces it used to carry, + // so the state a save starts from still holds what only they have + // (#2630). + const namespace = parseSecretsNamespace(raw); + const fallbacks = + namespace === undefined ? await recordedNamespaces(filePath) : []; + return joinSnapshot(snapshot, secretStore, namespace, fallbacks); }); } diff --git a/docs/secret-storage.md b/docs/secret-storage.md index d4da053cb1..0b1012e8e3 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -17,7 +17,7 @@ These values are stored as secrets: They are kept out of `mcp.json` so that sharing, committing or syncing the file does not leak credentials (#1356). When the Inspector saves an entry to a durable store, it leaves each `env` key in `mcp.json` with an empty value and omits the client secret; the real values live in the store. `headers` are **not** moved: they are saved in `mcp.json` exactly as written, so a header that carries a credential stays in the file. [MCP server configuration](./mcp-server-configuration.md) describes what that means for other tools reading the same file. -Acquired tokens follow the same rule for the OAuth state file: `oauth.json` keeps only non-secret state (flow bookkeeping, discovered metadata, public client ids), and the tokens and client secrets it used to hold live in the secret store. Each state file's store entries are scoped by a `secretsNamespace` UUID stamped into the file on its first write, so two state files (for example, per-profile `MCP_INSPECTOR_OAUTH_STATE_PATH` values) that authorize against the same server keep separate entries even in a shared store such as the OS keychain. A pre-namespace file is adopted transparently on its first save — its existing un-namespaced entries move under the new namespace, so the adopting file keeps its credentials. That migration deletes its sources, so it runs only under the real cross-process file lock; on a box where the lock cannot be taken, the save fails with a retryable error instead of racing a concurrent adopter — if you cannot make the lock directory writable there, the escape is to clear the file's stored OAuth state and re-authorize, since clearing does not migrate and a fresh state file mints its namespace without the lock. The namespace stamp is also one-way across versions: an Inspector older than the namespace (≤ 2.9.x) resolves only un-namespaced entries, so downgrading after adoption logs you out, and its next save strips the stamp — alternating old and new versions on one state file therefore re-adopts under a fresh namespace each time. The earlier namespace's entries are not stranded by this: each state file keeps a sidecar ledger, `.namespaces.json`, recording every namespace it has used and the servers and IdP issuers given store entries under each, and the next re-adoption (or removal of the state file) purges every recorded namespace the file no longer carries — on every store backend, the OS keychain included, since the ledger names each entry and nothing has to be enumerated. The same purge cleans up after a state file deleted by hand, at the next save to that path. Entries are recorded per store backend (the keychain, or a particular `secrets.json`), and only a run using that backend purges them — so a run that falls back to a different store leaves the keychain's record for the next keychain run rather than "purging" an empty store and forgetting it. A keychain run also purges what was recorded against a `secrets.json`, since moving that file into the keychain once one is available copies its entries across; and when a `secrets.json` run purges its own record, the record passes to the keychain rather than being forgotten, so those copies are still cleaned up the next time the keychain is in use. Like adoption's migration, the purge runs only under the real cross-process file lock; a namespace whose purge fails stays recorded and is retried on the next save, with a warning. One limit remains: if the ledger is deleted along with its state file, the entries it recorded cannot be found on the keyring backend, which cannot list its entries; on the `file` backend they remain visible in `secrets.json` (ids beginning `oauth++`) and can be removed by hand. If more than one pre-namespace state file was sharing a server's un-namespaced entry, the first one to adopt takes it with it, and each remaining pre-namespace profile re-authorizes that server once — its copy was already being overwritten by every other profile's saves, which is the bug the namespace fixes. Removing a profile that is still pre-namespace likewise purges the shared un-namespaced entries, as removal always has: the file being deleted is the store's only index of them, so leaving them would strand credentials nothing could find or clear again. A pre-existing `oauth.json` that still carries plaintext tokens is migrated on first read — the tokens move into the store and the file is rewritten without them — but only when the store is durable; under the `memory` store the file is left as-is, since it is still the only durable copy. A token entry so malformed it cannot be a credential (for example, a hand-edited value of the wrong type) is left in the file rather than migrated, so it stays visible and clearable. `MCP_INSPECTOR_PERSIST_TOKENS=all|access|none` controls which acquired tokens are persisted at all (see [Environment variables](./environment-variables.md#secret-store)). +Acquired tokens follow the same rule for the OAuth state file: `oauth.json` keeps only non-secret state (flow bookkeeping, discovered metadata, public client ids), and the tokens and client secrets it used to hold live in the secret store. Each state file's store entries are scoped by a `secretsNamespace` UUID stamped into the file on its first write, so two state files (for example, per-profile `MCP_INSPECTOR_OAUTH_STATE_PATH` values) that authorize against the same server keep separate entries even in a shared store such as the OS keychain. A pre-namespace file is adopted transparently on its first save — its existing un-namespaced entries move under the new namespace, so the adopting file keeps its credentials. That migration deletes its sources, so it runs only under the real cross-process file lock; on a box where the lock cannot be taken, the save fails with a retryable error instead of racing a concurrent adopter — if you cannot make the lock directory writable there, the escape is to clear the file's stored OAuth state and re-authorize, since clearing does not migrate and a fresh state file mints its namespace without the lock. The namespace stamp is also one-way across versions: an Inspector older than the namespace (≤ 2.9.x) resolves only un-namespaced entries, so downgrading after adoption logs you out, and its next save strips the stamp — alternating old and new versions on one state file therefore re-adopts under a fresh namespace each time. The earlier namespace's entries are not stranded by this: each state file keeps a sidecar ledger, `.namespaces.json`, recording every namespace it has used and the servers and IdP issuers given store entries under each, and the next re-adoption (or removal of the state file) purges every recorded namespace the file no longer carries. Before re-adoption purges anything, it carries over what the older version could not see: for every server and IdP issuer the file still names, any secret the un-namespaced entries lack is copied from the namespaces the file used to carry, newest first, so a confidential client's `client_secret` (which the older version saved the `client_id` for, but could not reach) survives the round trip; reads of an unstamped file fall back the same way, so the state a save starts from already holds it. Only what nothing references any longer is purged — on every store backend, the OS keychain included, since the ledger names each entry and nothing has to be enumerated. The same purge cleans up after a state file deleted by hand, at the next save to that path. Entries are recorded per store backend (the keychain, or a particular `secrets.json`), and only a run using that backend purges them — so a run that falls back to a different store leaves the keychain's record for the next keychain run rather than "purging" an empty store and forgetting it. A keychain run also purges what was recorded against a `secrets.json`, since moving that file into the keychain once one is available copies its entries across; and when a `secrets.json` run purges its own record, the record passes to the keychain rather than being forgotten, so those copies are still cleaned up the next time the keychain is in use. Like adoption's migration, the purge runs only under the real cross-process file lock; a namespace whose purge fails stays recorded and is retried on the next save, with a warning. One limit remains: if the ledger is deleted along with its state file, the entries it recorded cannot be found on the keyring backend, which cannot list its entries; on the `file` backend they remain visible in `secrets.json` (ids beginning `oauth++`) and can be removed by hand. If more than one pre-namespace state file was sharing a server's un-namespaced entry, the first one to adopt takes it with it, and each remaining pre-namespace profile re-authorizes that server once — its copy was already being overwritten by every other profile's saves, which is the bug the namespace fixes. Removing a profile that is still pre-namespace likewise purges the shared un-namespaced entries, as removal always has: the file being deleted is the store's only index of them, so leaving them would strand credentials nothing could find or clear again. A pre-existing `oauth.json` that still carries plaintext tokens is migrated on first read — the tokens move into the store and the file is rewritten without them — but only when the store is durable; under the `memory` store the file is left as-is, since it is still the only durable copy. A token entry so malformed it cannot be a credential (for example, a hand-edited value of the wrong type) is left in the file rather than migrated, so it stays visible and clearable. `MCP_INSPECTOR_PERSIST_TOKENS=all|access|none` controls which acquired tokens are persisted at all (see [Environment variables](./environment-variables.md#secret-store)). ## How the store is chosen From 34d6345a146eb15c4b555e363f5474913baf9ba6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:19:22 -0400 Subject: [PATCH 11/73] fix(cli): skip a slot whose tokens changed during the refresh (#2574) Copilot review on #2666: - The CLI write-back now applies the rotation only while the slot still holds the refresh token the grant spent, so a sign-out or same-issuer re-authorization made during the grant is kept. - A retry's candidate set now includes fields earlier attempts touched, and deletes count as ours only when the field existed, so a racer removing an issuer-keyed entry between attempts cannot have its secrets resurrected by a failing retry's rollback. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/README.md | 2 +- clients/cli/__tests__/stored-auth.test.ts | 72 +++++++++++++++++++ clients/cli/src/cli.ts | 16 +++-- .../storage/oauth-write-convergence.test.ts | 49 +++++++++++++ core/auth/node/oauth-persist-file.ts | 19 +++-- 5 files changed, 148 insertions(+), 10 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 5e8b1fd444..e6c53b6a77 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -430,7 +430,7 @@ For the common case where OAuth was already completed in the **web inspector on **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. -**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The write-back updates only the refreshed issuer slot's tokens, against the entry as it is on disk at write time, so a same-server change made while the grant was in flight (say, a web OAuth flow against another authorization server) is kept rather than overwritten (#2574). The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. +**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The write-back updates only the refreshed issuer slot's tokens, against the entry as it is on disk at write time, so a same-server change made while the grant was in flight (say, a web OAuth flow against another authorization server) is kept rather than overwritten, and a slot whose tokens were cleared or replaced meanwhile is left as it is (#2574). The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover. diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 7167720863..3147baf217 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -1420,6 +1420,78 @@ describe("issuer-keyed stored auth (#2517)", () => { } }); + it("keeps a slot whose tokens were cleared during the refresh cleared", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + byIssuer: { + [ISSUER]: { clientInformation: SLOT.clientInformation }, + }, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]?.tokens).toBeUndefined(); + expect(persisted?.byIssuer?.[ISSUER]?.clientInformation).toEqual( + SLOT.clientInformation, + ); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps a same-issuer re-authorization made during the refresh", async () => { + const path = fixture(); + const reauthed = { + access_token: "web-access", + token_type: "Bearer", + refresh_token: "web-refresh", + }; + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + byIssuer: { [ISSUER]: { ...SLOT, tokens: reauthed } }, + })); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]?.tokens).toEqual(reauthed); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps a legacy entry's tokens replaced during the refresh", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "legacy-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "legacy-client" }, + serverMetadata: { + issuer: ISSUER, + token_endpoint: `${ISSUER}/token`, + }, + }, + }); + const replaced = { + access_token: "web-access", + token_type: "Bearer", + refresh_token: "web-refresh", + }; + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + tokens: replaced, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + expect((await readOAuthStore(path))?.servers[SERVER]?.tokens).toEqual( + replaced, + ); + } finally { + rmSync(path, { force: true }); + } + }); + it("does not resurrect an issuer slot removed during the refresh", async () => { const path = fixture(); try { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index ba65b48e73..38b0dcdf6d 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -531,15 +531,21 @@ export async function refreshStoredAuthToken( // // The rotated tokens go back to the slot they were read from: the active // issuer's `byIssuer` entry (#2517), or the legacy top-level fields for a - // pre-issuer entry — never a slot the old tokens did not come from. A slot - // or entry removed meanwhile stays removed; the fresh token is still - // returned for this run. + // pre-issuer entry — never a slot the old tokens did not come from. And only + // while that slot still holds the refresh token this grant spent: a slot + // whose tokens were cleared or replaced meanwhile (a sign-out, a same-issuer + // re-authorization) keeps its fresh state, as does a removed slot or entry. + // The fresh token is still returned for this run either way. const { issuer } = credentials; + const unchanged = (current: OAuthTokens | undefined): boolean => + current?.refresh_token === refreshToken; await updateOAuthServerEntry(statePath, found.key, (fresh) => { if (fresh === undefined) return undefined; - if (issuer === undefined) return { ...fresh, tokens }; + if (issuer === undefined) { + return unchanged(fresh.tokens) ? { ...fresh, tokens } : fresh; + } const slot = getOwnEntry(fresh.byIssuer, issuer); - if (slot === undefined) return fresh; + if (slot === undefined || !unchanged(slot.tokens)) return fresh; return { ...fresh, // A computed key defines an own property, so `__proto__` is safe. diff --git a/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts b/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts index bea288f092..aba436e979 100644 --- a/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts +++ b/clients/web/src/test/integration/storage/oauth-write-convergence.test.ts @@ -30,6 +30,7 @@ import { oauthSecretServerId, LEGACY_TOKENS_FIELD, LEGACY_CLIENT_SECRET_FIELD, + issuerTokensField, } from "@inspector/core/auth/node/oauth-secrets.js"; import { writeStoreFile } from "@inspector/core/storage/store-io.js"; import type { OAuthPersistSnapshot } from "@inspector/core/auth/oauth-persist.js"; @@ -725,4 +726,52 @@ describe("updateOAuthServerEntry", () => { const read = await readOAuthStore(filePath, store); expect(read?.servers[SERVER_A]?.tokens).toEqual(serverState("a").tokens); }); + + it("does not resurrect secrets a racer deleted with the entry when a retry fails", async () => { + // Attempt 1 rotates an issuer slot's tokens and is clobbered by a writer + // that removed the server outright, secrets included. The retry's + // updater sees no entry and keeps it gone, then its file write fails: + // the rollback must not restore the slot's pre-call secrets with no file + // entry left to index them. Issuer-keyed on purpose — an absent entry + // still names every legacy field, but not a per-issuer one. + const ISSUER = "https://as.example"; + await writeOAuthSections( + filePath, + snapshotOf({ + [SERVER_B]: { + activeIssuer: ISSUER, + byIssuer: { [ISSUER]: { tokens: serverState("b").tokens } }, + }, + }), + { servers: [SERVER_B] }, + store, + ); + const tokensField = issuerTokensField(ISSUER); + expect(await store.get(idOf(SERVER_B), tokensField)).not.toBeNull(); + let writes = 0; + hook.beforeWrite = () => { + writes += 1; + if (writes === 2) throw new Error("disk full"); + }; + hook.afterWrite = async (path) => { + if (writes !== 1) return; + writeFileSync(path, onlyA); + await store.deleteAllForServer(idOf(SERVER_B)); + }; + + await expect( + updateOAuthServerEntry( + filePath, + SERVER_B, + (entry) => + entry && { + ...entry, + byIssuer: { [ISSUER]: { tokens: NEW_TOKENS } }, + }, + store, + ), + ).rejects.toThrow(/disk full/); + + expect(await store.get(idOf(SERVER_B), tokensField)).toBeNull(); + }); }); diff --git a/core/auth/node/oauth-persist-file.ts b/core/auth/node/oauth-persist-file.ts index 1002e7e90d..6e675fbd84 100644 --- a/core/auth/node/oauth-persist-file.ts +++ b/core/auth/node/oauth-persist-file.ts @@ -924,11 +924,19 @@ async function writeOAuthSectionsFrom( // would read as an update and skip the purge below. const next = getOwnEntry(snapshot.servers, url); // Candidates span the old and new shapes so a removed issuer's - // fields are deleted, not orphaned in the store. + // fields are deleted, not orphaned in the store — plus every field + // an earlier attempt touched. A racer can drop an entry (or slot) + // between attempts, taking its secrets with it; without these the + // retry would snapshot nothing for them, and a failing retry's + // rollback would restore the pre-call values with no file entry + // left to index them. const candidates = [ ...new Set([ ...serverSecretFields(getOwnEntry(disk?.servers, url)), ...serverSecretFields(next), + ...[...restoreBaseline.values()] + .filter((prior) => prior.serverId === serverId) + .map(({ field }) => field), ]), ]; const entryPrior = await snapshotSecretFields( @@ -937,12 +945,18 @@ async function writeOAuthSectionsFrom( candidates, ); priorSecrets.push(...entryPrior); + const entryPriorValue = new Map( + entryPrior.map(({ field, value }) => [field, value]), + ); if (next === undefined) { // A failed purge propagates and aborts the write: committing a // file without the entry while its secrets may linger in the // store would orphan them, and re-adding the server later could // resurrect the stale credentials. for (const field of candidates) { + // Changed fields only — see `ourWrites`: an already-absent + // field is a deletion this attempt did not make. + if (entryPriorValue.get(field) === null) continue; ourWrites.set(priorKey({ serverId, field }), null); } await secretStore.deleteAllForServer(serverId); @@ -956,9 +970,6 @@ async function writeOAuthSectionsFrom( continue; } const { residue, secrets } = splitServerOAuthState(next, policy); - const entryPriorValue = new Map( - entryPrior.map(({ field, value }) => [field, value]), - ); for (const field of candidates) { const value = secrets[field] ?? null; // Changed fields only — see `ourWrites`. From fbdaa15c36f107f752a1298c970a6c17bbc4d783 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:26:02 -0400 Subject: [PATCH 12/73] fix(smoke): bound the docker info probe (#2625) Addresses Copilot review round 4 on #2662: an unbounded docker info could hang the gate on a wedged or remote daemon. The probe now times out after a measured 10s (healthy: 0.07-0.12s; dead remote: 32s) and reaches the loud skip with the reason. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/lib/firefox-container.mjs | 44 ++++++++++++++++++----- scripts/lib/firefox-container.test.mjs | 49 ++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/scripts/lib/firefox-container.mjs b/scripts/lib/firefox-container.mjs index 37c6dc12fd..00fdd6bb72 100644 --- a/scripts/lib/firefox-container.mjs +++ b/scripts/lib/firefox-container.mjs @@ -182,10 +182,37 @@ function exportWorkingTree(repoRoot, dest) { return paths.length; } -/** Whether a Docker daemon answers. `docker info` fails when only the CLI is installed. */ -function dockerAvailable() { - const probe = spawnSync("docker", ["info"], { stdio: "ignore" }); - return probe.status === 0; +/** + * How long `docker info` may take before the daemon counts as unreachable. + * + * Measured on macOS 27 / Docker Desktop: a healthy daemon answers in + * 0.07–0.12s, while an unreachable remote context (`DOCKER_HOST` pointing at a + * dead address) took 32s to fail on the CLI's own dial timeout — and a wedged + * daemon need not fail at all. 10s is ~80x the healthy case and well under + * the dead-host one, so the gate reaches the loud skip instead of hanging. + */ +export const DOCKER_PROBE_TIMEOUT_MS = 10_000; + +/** + * Whether a Docker daemon answers, and if not, why. `docker info` fails when + * only the CLI is installed; the timeout bounds a wedged or remote daemon. + */ +export function probeDocker(spawn = spawnSync) { + const probe = spawn("docker", ["info"], { + stdio: "ignore", + timeout: DOCKER_PROBE_TIMEOUT_MS, + }); + if (probe.error?.code === "ETIMEDOUT") { + return { + ok: false, + reason: `\`docker info\` did not answer within ${DOCKER_PROBE_TIMEOUT_MS / 1000}s`, + }; + } + if (probe.error) return { ok: false, reason: probe.error.message }; + if (probe.status !== 0) { + return { ok: false, reason: "no Docker daemon is reachable" }; + } + return { ok: true }; } /** The caller's `uid:gid`, or undefined where POSIX ids do not exist (Windows). */ @@ -202,7 +229,7 @@ function hostOwner() { * without Docker. */ export const defaultContainerDeps = { - dockerAvailable, + probeDocker: () => probeDocker(), readLockfile: (repoRoot) => readFileSync(join(repoRoot, "clients/web/package-lock.json"), "utf8"), makeTempDir: (engine) => @@ -226,7 +253,7 @@ export function runInContainer( deps = {}, ) { const { - dockerAvailable, + probeDocker, readLockfile, makeTempDir, exportWorkingTree, @@ -244,11 +271,12 @@ export function runInContainer( ? `run-engine-smokes: ${engine} routed to the container by ${MODE_ENV}=container` : `run-engine-smokes: ${engine} cannot launch natively on this host (${ISSUE})`; - if (!dockerAvailable()) { + const docker = probeDocker(); + if (!docker.ok) { warn( [ "", - `⚠️ ${banner}, and no Docker daemon is reachable.`, + `⚠️ ${banner}, and Docker is unavailable: ${docker.reason}.`, `⚠️ SKIPPING the ${engine} engine pass — this is NOT a pass. Start Docker`, `⚠️ (or set ${MODE_ENV}=native to try a native launch) and re-run`, `⚠️ \`npm run smoke:web:${engine}\` before pushing.`, diff --git a/scripts/lib/firefox-container.test.mjs b/scripts/lib/firefox-container.test.mjs index ed2115a862..91a15c4f42 100644 --- a/scripts/lib/firefox-container.test.mjs +++ b/scripts/lib/firefox-container.test.mjs @@ -13,11 +13,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it } from "node:test"; import { + DOCKER_PROBE_TIMEOUT_MS, ISSUE, MODE_ENV, dockerRunArgs, exportablePaths, pinnedPlaywrightVersion, + probeDocker, playwrightImage, runInContainer, shouldUseContainer, @@ -179,6 +181,43 @@ describe("exportablePaths", () => { }); }); +describe("probeDocker", () => { + it("bounds the probe and reports a timeout as unavailable", () => { + let opts; + const result = probeDocker((_cmd, _args, o) => { + opts = o; + return { + status: null, + error: Object.assign(new Error("t"), { code: "ETIMEDOUT" }), + }; + }); + assert.equal(opts.timeout, DOCKER_PROBE_TIMEOUT_MS); + assert.equal(result.ok, false); + assert.match(result.reason, /did not answer within 10s/); + }); + + it("distinguishes a spawn error, a failing daemon and a healthy one", () => { + assert.deepEqual( + probeDocker(() => ({ + status: null, + error: new Error("spawn docker ENOENT"), + })), + { ok: false, reason: "spawn docker ENOENT" }, + ); + assert.deepEqual( + probeDocker(() => ({ status: 1 })), + { + ok: false, + reason: "no Docker daemon is reachable", + }, + ); + assert.deepEqual( + probeDocker(() => ({ status: 0 })), + { ok: true }, + ); + }); +}); + describe("runInContainer", () => { const LOCK = JSON.stringify({ packages: { "node_modules/playwright": { version: "1.62.1" } }, @@ -188,7 +227,7 @@ describe("runInContainer", () => { function fakes(overrides = {}) { const calls = { docker: [], removed: [], warned: [], errored: [] }; const deps = { - dockerAvailable: () => true, + probeDocker: () => ({ ok: true }), readLockfile: () => LOCK, makeTempDir: () => "/tmp/export", exportWorkingTree: () => 3, @@ -209,7 +248,12 @@ describe("runInContainer", () => { runInContainer({ repoRoot: "/repo", engine: "firefox", env: {} }, deps); it("skips loudly and exits 0 when no Docker daemon answers", () => { - const { calls, deps } = fakes({ dockerAvailable: () => false }); + const { calls, deps } = fakes({ + probeDocker: () => ({ + ok: false, + reason: "no Docker daemon is reachable", + }), + }); assert.equal(run(deps), 0); assert.equal(calls.docker.length, 0, "must not try to run docker"); assert.equal(calls.removed.length, 0, "nothing was created to remove"); @@ -217,6 +261,7 @@ describe("runInContainer", () => { assert.match(banner, /SKIPPING the firefox engine pass/); assert.match(banner, /NOT a pass/); assert.match(banner, new RegExp(ISSUE)); + assert.match(banner, /no Docker daemon is reachable/); }); it("runs the pinned image over the export as the caller, then cleans up", () => { From 7445124909298f7846931f712027274e7518e35e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:46:55 -0400 Subject: [PATCH 13/73] docs(agents): pace bulk GitHub writes under the secondary rate limit (#2667) Bulk triage and board work tripped GitHub's secondary rate limit while gh api rate_limit still showed quota. Add the pacing rule to the Issue-driven Work Style section of AGENTS.md. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 40b3ba32fa..8d7c0031fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,7 @@ skills; the rules are here. - **Attach screenshots as proof of functionality** for any web-UI or TUI change. Put them in a **`pr-screenshots/`** folder off the repo root — it is **gitignored**, so the images are staged for upload and never committed — and name them for what they show. - ⚠️ Closing keywords only auto-link and auto-close for PRs targeting the **default branch** (`main`). A v2 PR targets `v2/main`, so `Closes #N` there is only a cross-reference and the card shows no linked PR. **Link it explicitly** with the `addCloseIssueReferences` GraphQL mutation right after opening the PR (recipe in `pr-flow`, step 6). **On merge, manually close the issue and move the card to Done.** Keep the line anyway, so the issues close if/when `v2/main` reaches `main`. - **If new tasks are discovered during development, create issues** and add them to the board. +- **Pace bulk GitHub writes: 1 s between requests, never in parallel, under 80 a minute and 500 an hour.** The secondary limit refuses while `gh api rate_limit` still shows quota, and most `gh` writes are GraphQL. After a refusal, wait out `retry-after`, or a minute without one; a refused create can still land, so look before retrying. ### Responding to Code Reviews From 75c2782c6408dfb80fa61bf1b9a683b275375d49 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:45:17 -0400 Subject: [PATCH 14/73] fix(auth): pass empty fallbacks to joinSnapshot in updateOAuthServerEntry (#2676) #2666 added a joinSnapshot call with three arguments; #2671 added a fourth fallbacks parameter. Each passed CI alone, so v2/main stopped typechecking once both merged. The write path always holds a resolved namespace, so there is no unstamped file to fall back from. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- core/auth/node/oauth-persist-file.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/auth/node/oauth-persist-file.ts b/core/auth/node/oauth-persist-file.ts index ee34ad8b05..d8d0aed101 100644 --- a/core/auth/node/oauth-persist-file.ts +++ b/core/auth/node/oauth-persist-file.ts @@ -672,6 +672,10 @@ export async function updateOAuthServerEntry( }, secretStore, namespace, + // Always a resolved namespace here (adopted or minted for + // this save), so there is no unstamped file to fall back + // from — fallbacks are a read-path concern (#2676). + [], ) ).servers, serverKey, From f9a265e8d7eb3a3015091c5b424e4eec11d31409 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:45:32 -0400 Subject: [PATCH 15/73] feat(web): hide 'Run as task' on modern-era connections (#2674) Under SEP-2663 task creation is server-directed: the server decides per request whether to return a task handle, so the client has no per-call choice to offer. The switch is now legacy-only, gated by a new pure offersRunAsTask(protocolEra, capabilities) helper, and the modernTasks prop is gone from ToolDetailPanel / ToolsScreen / InspectorView. No routing change is needed: on a modern connection InspectorClient.callTool already dispatches through the ext-tasks pipeline (preference "allow"), which declares the extension and follows a returned handle to completion with the same Tasks-screen events and progress as callToolStream; on v2 "allow" and "prefer" are identical on the wire and retentionMs is v1-only. mcpdo's --task flag cannot be hidden per era (its command tree is static), so its help text now says it applies to legacy servers. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/mcpdo/src/connection/mcp.ts | 9 +++- clients/web/src/App.tsx | 5 +-- .../ToolDetailPanel/ToolDetailPanel.test.tsx | 36 ---------------- .../ToolDetailPanel/ToolDetailPanel.tsx | 43 +++++++------------ .../screens/ToolsScreen/ToolsScreen.tsx | 11 +++-- .../views/InspectorView/InspectorView.tsx | 6 --- .../components/views/InspectorView/types.ts | 6 ++- clients/web/src/hooks/useServerCommands.tsx | 11 +++-- clients/web/src/utils/toolUtils.test.ts | 28 ++++++++++++ clients/web/src/utils/toolUtils.ts | 24 ++++++++++- 10 files changed, 94 insertions(+), 85 deletions(-) diff --git a/clients/mcpdo/src/connection/mcp.ts b/clients/mcpdo/src/connection/mcp.ts index 131e374238..e4b200aa10 100644 --- a/clients/mcpdo/src/connection/mcp.ts +++ b/clients/mcpdo/src/connection/mcp.ts @@ -1334,7 +1334,14 @@ function registerRpcCommands(program: CommandType): void { parseKeyValue, {}, ) - .option("--task", "Task-augmented tool call (callToolStream)") + // Legacy-only choice: on a modern (SEP-2663) connection the server + // decides per request whether to return a task, and an ordinary call + // already follows one to completion, so the flag changes nothing on + // the wire there (#2674). + .option( + "--task", + "Request a task-augmented tool call (legacy servers; on modern the server decides)", + ) .option("--app-info", "Probe MCP App metadata only"); cmd.action( async ( diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 90f233ebb7..5efe762bda 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -109,6 +109,7 @@ import { } from "./components/groups/PendingClientRequestModal/PendingClientRequestModal"; import { downloadJsonFile } from "./lib/downloadFile"; import { serverWithDraftSettings } from "./utils/serverWithDraftSettings"; +import { offersRunAsTask } from "./utils/toolUtils"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink"; @@ -1866,9 +1867,7 @@ function App() { toolsUi: ui.toolsUi, toolCallState, toolsPagination: toolsPaginationControls, - serverSupportsTaskToolCalls: - !!capabilities?.tasks?.requests?.tools?.call || - (inspectorClient?.isTasksExtensionNegotiated() ?? false), + serverSupportsTaskToolCalls: offersRunAsTask(protocolEra, capabilities), onToolsUiChange, onCallTool: dispatchCallTool, onCancelToolCall, diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index 153874944b..c9c699700d 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -357,42 +357,6 @@ describe("ToolDetailPanel", () => { expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument(); }); - it("shows the toggle for a task-forbidden tool on a modern connection (#1631)", () => { - // On modern (SEP-2663) task creation is server-directed, so any tool can - // become a task — the toggle is offered regardless of per-tool taskSupport. - renderWithMantine( - , - ); - const toggle = screen.getByLabelText("Run as task"); - expect(toggle).not.toBeChecked(); - expect(toggle).not.toBeDisabled(); - }); - - it("routes the user's runAsTask choice on a modern connection (#1631)", async () => { - const user = userEvent.setup(); - const onExecute = vi.fn(); - renderWithMantine( - , - ); - // The toggle is checked (user's choice), and executing forwards true. - expect(screen.getByLabelText("Run as task")).toBeChecked(); - await user.click(screen.getByRole("button", { name: "Execute Tool" })); - expect(onExecute).toHaveBeenCalledWith(true); - }); - it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => { renderWithMantine( ; isExecuting: boolean; progress?: ToolProgress; - /** Whether the connected server advertises task-augmented tool calls. */ - serverSupportsTaskToolCalls: boolean; /** - * Modern (2026-07-28) connection with the `io.modelcontextprotocol/tasks` - * extension negotiated (SEP-2663). Task creation is server-directed there, so - * "Run as task" is offered for ANY tool (not just ones declaring per-tool - * `taskSupport`, which is the legacy mechanism). Defaults to false (legacy). + * Whether to offer the per-call "Run as task" choice: a legacy server that + * advertises `tasks.requests.tools.call`. Always false on a modern + * connection, where task creation is server-directed (#2674). */ - modernTasks?: boolean; - /** User's "Run as task" preference (meaningful for `optional` tools and, on - * modern connections, any tool). */ + serverSupportsTaskToolCalls: boolean; + /** User's "Run as task" preference (meaningful for `optional` tools). */ runAsTask: boolean; onRunAsTaskChange: (value: boolean) => void; onFormChange: (values: Record) => void; @@ -213,7 +209,6 @@ export function ToolDetailPanel({ isExecuting, progress, serverSupportsTaskToolCalls, - modernTasks = false, runAsTask, onRunAsTaskChange, onFormChange, @@ -256,28 +251,22 @@ export function ToolDetailPanel({ // single expandable control (aria-expanded + aria-controls). const descriptionRegionId = useId(); - // Show the toggle when the server supports task tool calls and either the - // connection is modern (task creation is server-directed there, so any tool - // may become a task) or the tool doesn't forbid per-tool task support - // (legacy). `required` tools are forced on (checked + disabled); `optional` - // and (on modern) any tool follow the user's `runAsTask` choice. - // - // NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY - // ignored. Under SEP-2663 task creation is decided by the server per request, - // not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does - // not gate the affordance — the server may return a task for any call. The - // toggle just declares intent to poll a returned handle. + // Show the toggle when the (legacy) server supports task tool calls and the + // tool doesn't forbid per-tool task support. `required` tools are forced on + // (checked + disabled); `optional` tools follow the user's `runAsTask` + // choice. A modern (SEP-2663) connection never gets here with + // `serverSupportsTaskToolCalls` set: task creation is server-directed there, + // so there is no per-call choice to offer (#2674). const taskSupport = getTaskSupport(tool); const showRunAsTask = - serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden"); + serverSupportsTaskToolCalls && taskSupport !== "forbidden"; // Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required` - // value must not route through callToolStream when the toggle is hidden. On - // legacy, a tool's taskSupport is only considered when the server advertises - // `tasks.requests.tools.call`; on modern, the user's choice governs any tool. + // value must not route through callToolStream when the toggle is hidden. A + // tool's taskSupport is only considered when the server advertises + // `tasks.requests.tools.call`. const effectiveRunAsTask = showRunAsTask && - (taskSupport === "required" || - ((taskSupport === "optional" || modernTasks) && runAsTask)); + (taskSupport === "required" || (taskSupport === "optional" && runAsTask)); // Two distinct reasons the arguments aren't sendable, and this panel used to // check neither — Execute was gated on `isExecuting` alone, so a required diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 38173396de..9b168772f7 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -67,11 +67,12 @@ export interface ToolsScreenProps { callState?: ToolCallState; ui: ToolsUiState; listChanged: boolean; - /** Whether the connected server advertises task-augmented tool calls. */ + /** + * Whether to offer the per-call "Run as task" choice: a legacy server that + * advertises `tasks.requests.tools.call`. Always false on a modern + * connection, where task creation is server-directed (#2674). + */ serverSupportsTaskToolCalls: boolean; - /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered - * for any tool (server-directed task creation). */ - modernTasks?: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ @@ -166,7 +167,6 @@ export function ToolsScreen({ ui, listChanged, serverSupportsTaskToolCalls, - modernTasks = false, onUiChange, onRefreshList, loadError, @@ -273,7 +273,6 @@ export function ToolsScreen({ isExecuting={isExecuting} progress={callState?.progress} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} - modernTasks={modernTasks} runAsTask={ui.runAsTask} onRunAsTaskChange={(value) => onUiChange({ ...ui, runAsTask: value }) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 0ab8f3b1e6..048837a617 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1202,12 +1202,6 @@ export function InspectorView({ listChanged={toolsListChanged} loadError={toolsLoadError} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} - modernTasks={ - protocolEra === "modern" && - initializeResult?.capabilities?.extensions?.[ - TASKS_EXTENSION_KEY - ] !== undefined - } onUiChange={onToolsUiChange} onRefreshList={onRefreshTools} pagination={toolsPagination} diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index 32d06bd143..538e6734c5 100644 --- a/clients/web/src/components/views/InspectorView/types.ts +++ b/clients/web/src/components/views/InspectorView/types.ts @@ -226,7 +226,11 @@ export interface ToolsPanelProps { toolCallState?: ToolCallState; /** Pagination controls for the Tools list (#1721). */ toolsPagination: ListPaginationControlsProps; - /** Whether the connected server advertises task-augmented tool calls. */ + /** + * Whether to offer the per-call "Run as task" choice: a legacy server that + * advertises `tasks.requests.tools.call`. Always false on a modern + * connection, where task creation is server-directed (#2674). + */ serverSupportsTaskToolCalls: boolean; onToolsUiChange: (next: ToolsUiState) => void; onCallTool: ( diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index e84d54f190..0cf9769dd5 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -336,10 +336,13 @@ export function useServerCommands({ // Legacy servers advertise task tool calls via // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate // the `io.modelcontextprotocol/tasks` extension and are server-directed: - // task creation is decided per-request by the server, so declaring the - // extension on the call (which the task path does) is what makes a returned - // task handle legal ("unsolicited handles"). Either era routes the flagged - // call through the streaming task pipeline. + // task creation is decided per-request by the server, and the UI offers no + // "Run as task" choice there (#2674). That needs no routing here, because + // on a modern connection `callTool` itself dispatches through the task + // pipeline (preference "allow"), which declares the extension and settles + // a returned handle exactly as `callToolStream` would — Tasks screen, + // progress and all. Only a legacy `required` tool still takes this branch + // on modern, since `callTool` rejects one up front. const serverSupportsTaskToolCalls = !!capabilities?.tasks?.requests?.tools?.call || inspectorClient.isTasksExtensionNegotiated(); diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index efe0211e8e..5f7ee4184b 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -3,6 +3,7 @@ import type { Tool } from "@modelcontextprotocol/client"; import { findToolByRowKey, hasInputFields, + offersRunAsTask, resolveDisplayLabel, toolRowKey, } from "./toolUtils"; @@ -155,3 +156,30 @@ describe("hasInputFields with root composition (#2123)", () => { expect(hasInputFields(tool({ type: "object" }))).toBe(false); }); }); + +describe("offersRunAsTask", () => { + const legacyTaskCaps = { tasks: { requests: { tools: { call: {} } } } }; + + it("offers the choice on a legacy server advertising tasks.requests.tools.call", () => { + expect(offersRunAsTask("legacy", legacyTaskCaps)).toBe(true); + }); + + it("offers the choice before the era is known, on the legacy capability alone", () => { + expect(offersRunAsTask(undefined, legacyTaskCaps)).toBe(true); + }); + + it("does not offer it on a legacy server without task tool calls", () => { + expect(offersRunAsTask("legacy", { tasks: {} })).toBe(false); + expect(offersRunAsTask("legacy", undefined)).toBe(false); + }); + + it("never offers it on a modern connection — task creation is server-directed (#2674)", () => { + const modernCaps = { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }; + expect(offersRunAsTask("modern", modernCaps)).toBe(false); + // Even a legacy capability carried on a modern connection does not bring + // the per-call choice back. + expect(offersRunAsTask("modern", legacyTaskCaps)).toBe(false); + }); +}); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 9d08d49972..77afb7df9d 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -1,4 +1,8 @@ -import type { Tool } from "@modelcontextprotocol/client"; +import type { + ProtocolEra, + ServerCapabilities, + Tool, +} from "@modelcontextprotocol/client"; import { declaresAnyFields } from "@inspector/core/json/rootUnion.js"; import { listRowKey } from "./listRowKey"; @@ -64,3 +68,21 @@ export function findToolByRowKey( if (key === undefined) return undefined; return tools.find((tool, index) => toolRowKey(tool.name, index) === key); } + +/** + * Whether the Tools tab offers a per-call "Run as task" choice: only on a + * legacy (2025-11-25) connection whose server advertises + * `tasks.requests.tools.call`. On a modern connection task creation is + * server-directed (SEP-2663) — the server decides per request whether to return + * a task — so there is no choice for the client to make, and every modern + * `callTool` already accepts a returned handle (#2674). A server that also + * carries the legacy capability on a modern connection does not change that. + */ +export function offersRunAsTask( + protocolEra: ProtocolEra | undefined, + capabilities: ServerCapabilities | undefined, +): boolean { + return ( + protocolEra !== "modern" && !!capabilities?.tasks?.requests?.tools?.call + ); +} From 77bb793fdd2fc9acb71eaf02c2fb351ab3374086 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:54:32 -0400 Subject: [PATCH 16/73] docs(skills): point test-servers at scripts/lib/ensure-test-servers.mjs (#2523) The test-servers skill named scripts/ensure-test-servers.mjs, a path that has never existed; the helper the scripts import lives under scripts/lib/. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/test-servers/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/test-servers/SKILL.md b/.claude/skills/test-servers/SKILL.md index 97ebfd68c2..fbde45e502 100644 --- a/.claude/skills/test-servers/SKILL.md +++ b/.claude/skills/test-servers/SKILL.md @@ -207,7 +207,7 @@ exist whichever one you are in: cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/ ``` -Scripts reach this through `scripts/ensure-test-servers.mjs`, which builds +Scripts reach this through `scripts/lib/ensure-test-servers.mjs`, which builds **unconditionally** (once per process per repo root). ⚠️ **Unconditional emit is not a clean.** A **deleted** source file leaves its From 2bc28da844522b3773aeb10d3f0eb1304b362dba Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:59:37 -0400 Subject: [PATCH 17/73] fix(mcpdo): route connections and OAuth through HTTPS_PROXY / HTTP_PROXY (#2642) mcpdo's daemon connection and front-end authorization environments set only `transport`, and InspectorClient always hands the transport a defined fetch, so the transport's createProxyFetch() fallback was never reached and mcpdo connected direct even with a proxy configured. Install `fetch: createProxyFetch()` on both environments, as the one-shot CLI and TUI do, and pass it to EMA leg 1 (IdP discovery and code exchange), which runs outside InspectorClient. The --relogin clear is local-only and needs no change. Adds a daemon test through a real forwarding proxy (proxied, and direct for a NO_PROXY host), wiring tests for the authorize and EMA paths, and lists mcpdo on the proxy rows in docs/environment-variables.md. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/mcpdo/__tests__/authorize.test.ts | 32 +++++ clients/mcpdo/__tests__/daemon-proxy.test.ts | 125 +++++++++++++++++++ clients/mcpdo/__tests__/ema.test.ts | 45 +++++++ clients/mcpdo/src/connection/authorize.ts | 11 +- clients/mcpdo/src/connection/ema.ts | 7 ++ clients/mcpdo/src/daemon/connections.ts | 11 +- docs/environment-variables.md | 4 +- 7 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 clients/mcpdo/__tests__/daemon-proxy.test.ts diff --git a/clients/mcpdo/__tests__/authorize.test.ts b/clients/mcpdo/__tests__/authorize.test.ts index ea73822cb0..f094e936c3 100644 --- a/clients/mcpdo/__tests__/authorize.test.ts +++ b/clients/mcpdo/__tests__/authorize.test.ts @@ -4,6 +4,8 @@ import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; const connectSpy = vi.fn(); const disconnectSpy = vi.fn().mockResolvedValue(undefined); const navigationSpy = vi.fn(); +const clientCtorSpy = vi.fn(); +const createProxyFetchSpy = vi.fn<() => typeof fetch | undefined>(); vi.mock("@inspector/core/cli/cliOAuth.js", () => ({ connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), @@ -18,11 +20,20 @@ vi.mock("@inspector/core/cli/cli-oauth-navigation.js", () => ({ vi.mock("@inspector/core/mcp/index.js", () => ({ InspectorClient: class { + constructor(...args: unknown[]) { + clientCtorSpy(...args); + } connect = vi.fn(); disconnect = disconnectSpy; }, })); +vi.mock("@inspector/core/mcp/node/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, createProxyFetch: () => createProxyFetchSpy() }; +}); + vi.mock("@inspector/core/client/runner.js", async (importOriginal) => { const actual = await importOriginal(); @@ -38,6 +49,27 @@ describe("authorizeInFrontend", () => { connectSpy.mockReset(); disconnectSpy.mockClear(); navigationSpy.mockClear(); + clientCtorSpy.mockClear(); + createProxyFetchSpy.mockReset(); + }); + + it("installs the proxy-aware fetch on the client environment (#2642)", async () => { + // `InspectorClient` always hands its transport a defined fetch, so the + // environment is the only place a proxy can go for OAuth discovery and + // token requests to reach it. + connectSpy.mockResolvedValue(undefined); + const proxyFetch: typeof fetch = vi.fn(); // a stand-in, never called + createProxyFetchSpy.mockReturnValue(proxyFetch); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = clientCtorSpy.mock.calls[0]?.[1] as { + environment: { fetch?: typeof fetch }; + }; + expect(options.environment.fetch).toBe(proxyFetch); }); it("no-ops for non-OAuth-capable (stdio) configs", async () => { diff --git a/clients/mcpdo/__tests__/daemon-proxy.test.ts b/clients/mcpdo/__tests__/daemon-proxy.test.ts new file mode 100644 index 0000000000..b754df3f55 --- /dev/null +++ b/clients/mcpdo/__tests__/daemon-proxy.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, request, type Server } from "node:http"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import { ConnectionRegistry } from "../src/daemon/connections.js"; + +/** + * The daemon routes its connections through HTTPS_PROXY / HTTP_PROXY, honoring + * NO_PROXY (#2642). + * + * Asserted behaviorally, through a real forwarding proxy, as the one-shot CLI's + * proxy test is: `InspectorClient` always hands the transport a defined fetch, + * so the transport-level proxy fallback never fires and the only thing that + * puts a proxy under the daemon is `fetch: createProxyFetch()` on its + * environment. Delete that line and the first case here goes red. + * + * Proxy variables are set for the WHOLE file and the ambient ones restored + * after it. `createProxyFetch`'s `EnvHttpProxyAgent` is memoized process-wide + * and captures the proxy URL when it is first built (only NO_PROXY is re-read + * per request), so the proxy must be listening before any request is made and + * stay listening until the last one. + */ +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", +] as const; + +describe("daemon outbound proxy (#2642)", () => { + const ambient: Record = {}; + const seen: string[] = []; + let proxy: Server; + + beforeAll(async () => { + for (const name of PROXY_VARS) { + ambient[name] = process.env[name]; + delete process.env[name]; + } + proxy = createServer((req, res) => { + seen.push(req.url ?? ""); + const target = new URL(req.url ?? ""); + const upstream = request( + { + host: target.hostname, + port: target.port, + path: target.pathname + target.search, + method: req.method, + headers: req.headers, + }, + (up) => { + res.writeHead(up.statusCode ?? 502, up.headers); + up.pipe(res); + }, + ); + upstream.on("error", () => { + res.writeHead(502); + res.end(); + }); + req.pipe(upstream); + }); + await new Promise((r) => proxy.listen(0, "127.0.0.1", () => r())); + const addr = proxy.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + process.env.HTTP_PROXY = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + for (const name of PROXY_VARS) { + const previous = ambient[name]; + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } + await new Promise((r) => proxy.close(() => r())); + }); + + /** Connects the daemon registry to a live server and lists its tools; returns the server URL. */ + async function listToolsThroughDaemon(): Promise { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const registry = new ConnectionRegistry(0); + try { + await server.start(); + await registry.connect({ + name: "remote", + serverConfig: { type: "streamable-http", url: server.url }, + serverIdentity: server.url, + }); + const tools = await registry.resolve("remote", false).client.listTools(); + expect(tools.tools.map((t) => t.name)).toContain("echo"); + return server.url; + } finally { + await registry.disconnect(undefined, false).catch(() => undefined); + await server.stop(); + } + } + + it("routes the connection through HTTP_PROXY", async () => { + seen.length = 0; + const serverUrl = await listToolsThroughDaemon(); + // The point of the test: it did not go direct. + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((u) => u.startsWith(serverUrl))).toBe(true); + }); + + it("connects direct to a NO_PROXY host", async () => { + seen.length = 0; + // The test server announces itself as `localhost`, so that is the host to + // exempt; NO_PROXY is re-read per request, so the memoized agent sees it. + process.env.NO_PROXY = "localhost"; + try { + await listToolsThroughDaemon(); + expect(seen).toEqual([]); + } finally { + delete process.env.NO_PROXY; + } + }); +}); diff --git a/clients/mcpdo/__tests__/ema.test.ts b/clients/mcpdo/__tests__/ema.test.ts index 6a68aa5641..081f3f7eda 100644 --- a/clients/mcpdo/__tests__/ema.test.ts +++ b/clients/mcpdo/__tests__/ema.test.ts @@ -11,6 +11,11 @@ import { const runRunnerInteractiveOAuth = vi.fn(); const startIdpOidcAuthorization = vi.fn(); const completeIdpOidcAuthorization = vi.fn(); +const createProxyFetch = vi.fn<() => typeof fetch | undefined>(); + +vi.mock("@inspector/core/mcp/node/proxyFetch.js", () => ({ + createProxyFetch: () => createProxyFetch(), +})); vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { const actual = @@ -57,6 +62,7 @@ describe("mcpdo ema helpers", () => { runRunnerInteractiveOAuth.mockReset(); startIdpOidcAuthorization.mockReset(); completeIdpOidcAuthorization.mockReset(); + createProxyFetch.mockReset(); }); afterEach(() => { @@ -258,6 +264,45 @@ describe("mcpdo ema helpers", () => { ); }); + it("emaLogin routes IdP discovery and the code exchange through the proxy-aware fetch (#2642)", async () => { + writeClientConfig(emaClientConfig()); + const proxyFetch: typeof fetch = vi.fn(); // a stand-in, never called + createProxyFetch.mockReturnValue(proxyFetch); + startIdpOidcAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://idp.example.com/authorize"), + }); + completeIdpOidcAuthorization.mockImplementation(async () => { + await seedIdpSession(); + return { idToken: fakeIdToken() }; + }); + runRunnerInteractiveOAuth.mockImplementation( + async (options: { + client: { + authenticate: () => Promise; + completeOAuthFlow: (code: string, iss?: string) => Promise; + }; + }) => { + await options.client.authenticate(); + await options.client.completeOAuthFlow("code-1", ISSUER); + return { kind: "success" }; + }, + ); + const originalWrite = process.stderr.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + const { emaLogin } = await import("../src/connection/ema.js"); + await emaLogin(); + } finally { + process.stderr.write = originalWrite; + } + expect(startIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ fetchFn: proxyFetch }), + ); + expect(completeIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ fetchFn: proxyFetch }), + ); + }); + it("emaLogin --relogin clears the existing connection and re-runs the flow", async () => { writeClientConfig(emaClientConfig()); await seedIdpSession(); diff --git a/clients/mcpdo/src/connection/authorize.ts b/clients/mcpdo/src/connection/authorize.ts index 6aff3f2a38..2854cfc4d5 100644 --- a/clients/mcpdo/src/connection/authorize.ts +++ b/clients/mcpdo/src/connection/authorize.ts @@ -11,7 +11,10 @@ import { loadRunnerClientConfig, } from "@inspector/core/client/runner.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; -import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + createProxyFetch, + createTransportNode, +} from "@inspector/core/mcp/node/index.js"; import { eraToVersionNegotiation, type InspectorClientEnvironment, @@ -55,6 +58,12 @@ export async function authorizeInFrontend( const environment: InspectorClientEnvironment = { transport: createTransportNode, + // HTTPS_PROXY / HTTP_PROXY / NO_PROXY, as in the one-shot CLI (#2642): + // `InspectorClient` always hands the transport a defined fetch, so the + // transport's own proxy fallback never fires — the proxy has to sit here, + // at the bottom of the stack, for OAuth discovery and token requests to + // reach it. Undefined when no proxy variable is set. + fetch: createProxyFetch(), }; const redirectUrlProvider = new MutableRedirectUrlProvider(); const callbackUrlConfig = parseRunnerOAuthCallbackUrl( diff --git a/clients/mcpdo/src/connection/ema.ts b/clients/mcpdo/src/connection/ema.ts index 89e11683d0..88e7a5353b 100644 --- a/clients/mcpdo/src/connection/ema.ts +++ b/clients/mcpdo/src/connection/ema.ts @@ -22,6 +22,7 @@ import { parseRunnerOAuthCallbackUrl, } from "@inspector/core/auth/node/runner-oauth-callback.js"; import { getClientConfigFilePath } from "@inspector/core/client/index.js"; +import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; import type { EnterpriseManagedAuthIdpConfig } from "@inspector/core/client/types.js"; import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js"; @@ -231,6 +232,10 @@ export async function runEmaIdpInteractiveFlow( const redirectUrlProvider = new MutableRedirectUrlProvider(); redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(callbackUrlConfig); + // IdP discovery and the code exchange run outside `InspectorClient`, so + // nothing else puts HTTPS_PROXY / HTTP_PROXY under them (#2642). Undefined + // when no proxy variable is set, which leaves the built-in fetch in place. + const fetchFn = createProxyFetch(); // Adapter over the server-bound runner-interactive-OAuth surface: EMA leg 1 // is server-less, so authenticate/completeOAuthFlow map straight onto the @@ -243,6 +248,7 @@ export async function runEmaIdpInteractiveFlow( idp: active, redirectUrl: redirectUrlProvider.redirectUrl, storage, + fetchFn, }); navigation.navigateToAuthorization(authorizationUrl); return authorizationUrl; @@ -256,6 +262,7 @@ export async function runEmaIdpInteractiveFlow( iss, redirectUrl: redirectUrlProvider.redirectUrl, storage, + fetchFn, }); }, /* v8 ignore next 2 -- only reached when options.authChallenge is set, which this flow never does */ diff --git a/clients/mcpdo/src/daemon/connections.ts b/clients/mcpdo/src/daemon/connections.ts index 0f175fb8fe..0faee6f9a1 100644 --- a/clients/mcpdo/src/daemon/connections.ts +++ b/clients/mcpdo/src/daemon/connections.ts @@ -9,7 +9,10 @@ import { type InspectorServerSettings, type MCPServerConfig, } from "@inspector/core/mcp/types.js"; -import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + createProxyFetch, + createTransportNode, +} from "@inspector/core/mcp/node/index.js"; import { buildOAuthConnectionState, ConsoleNavigation, @@ -816,6 +819,12 @@ async function createConnectionClient( ): Promise { const environment: InspectorClientEnvironment = { transport: createTransportNode, + // HTTPS_PROXY / HTTP_PROXY / NO_PROXY, as in the one-shot CLI (#2642). + // `InspectorClient` builds its fetch chain on `environment.fetch ?? fetch` + // and always hands the transport the result, so the transport's own + // `createProxyFetch()` fallback is never reached — leave this out and the + // daemon connects direct. Undefined when no proxy variable is set. + fetch: createProxyFetch(), }; const redirectUrlProvider = new MutableRedirectUrlProvider(); if (isOAuthCapableServerConfig(serverConfig)) { diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 392abcbfdd..a4d7feb092 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -109,8 +109,8 @@ When no store is configured, the choice also depends on whether the Inspector is | Variable | Read by | Default | Effect | | ---------------------------- | ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `HTTPS_PROXY` / `HTTP_PROXY` | web, CLI, TUI | unset | Route connections to remote HTTP/SSE servers, including OAuth discovery and token requests, through a proxy. Lowercase forms are honored too. See [HTTP proxy support](../clients/cli/README.md#http-proxy-support). | -| `NO_PROXY` | web, CLI, TUI | unset | Hosts exempted from the proxy. | +| `HTTPS_PROXY` / `HTTP_PROXY` | web, CLI, TUI, mcpdo | unset | Route connections to remote HTTP/SSE servers, including OAuth discovery and token requests, through a proxy. Lowercase forms are honored too. See [HTTP proxy support](../clients/cli/README.md#http-proxy-support). | +| `NO_PROXY` | web, CLI, TUI, mcpdo | unset | Hosts exempted from the proxy. | ## Node.js variables From 80d8730de9e69a49a37a5ce5725f6bff145c912f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 02:01:17 -0400 Subject: [PATCH 18/73] chore(pack:verify): assert skills/mcpdo ships and drive an mcpdo tool call (#2650) pack:verify now also: - asserts skills/mcpdo/SKILL.md is in the npm pack file list; - ESM-resolves every bare import in the installed clients/mcpdo/build from inside the package (import.meta.resolve; a CJS require.resolve falsely fails @modelcontextprotocol/ext-tasks, which exports only an `import` condition); - runs `mcpdo @test tools/call echo message:=pack-verify` through the packaged daemon and checks the echoed text. The mcpdo daemon dir is shortened to a one-character name so the socket path stays well under macOS's 104-byte sun_path limit. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- docs/publishing.md | 2 +- scripts/lib/bundle-bare-imports.mjs | 87 +++++++++++++++++++ scripts/lib/bundle-bare-imports.test.mjs | 103 ++++++++++++++++++++++ scripts/pack-and-verify.mjs | 105 ++++++++++++++++++++--- 4 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 scripts/lib/bundle-bare-imports.mjs create mode 100644 scripts/lib/bundle-bare-imports.test.mjs diff --git a/docs/publishing.md b/docs/publishing.md index 7892ead07c..3adbd21304 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -15,7 +15,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba ## `npm run pack:verify` — publish smoke against the real tarball -The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install ` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch; the same help reached through `npm exec @modelcontextprotocol/inspector`, which makes npm choose the bin itself as a bare `npx` does — the package ships two distinct bins (`mcp-inspector`, `mcpdo`), so that choice lands only on a bin named after the unscoped package, the root manifest's `inspector` alias, and every other check here names its bin and cannot see a missing alias ([#2651](https://github.com/modelcontextprotocol/inspector/issues/2651)); a real `--cli tools/list` over stdio, a prod `--web` boot that must serve `/` from the shipped `dist`, and — riding that same boot — an **MCP App rendered in headless Chromium** through the shipped sandbox proxy, connect → open app → `data-app-status="ready"` ([#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)). That last step shares its flow with `smoke:web:app` via `scripts/lib/mcp-app-flow.mjs` rather than copying it, since the deep-link shape is the part that rots silently; the *client* comes from the install while the App test server stays a repo fixture. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate` loop or of `local:gate`. +The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install ` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch; the same help reached through `npm exec @modelcontextprotocol/inspector`, which makes npm choose the bin itself as a bare `npx` does — the package ships two distinct bins (`mcp-inspector`, `mcpdo`), so that choice lands only on a bin named after the unscoped package, the root manifest's `inspector` alias, and every other check here names its bin and cannot see a missing alias ([#2651](https://github.com/modelcontextprotocol/inspector/issues/2651)); a real `--cli tools/list` over stdio, a prod `--web` boot that must serve `/` from the shipped `dist`, and — riding that same boot — an **MCP App rendered in headless Chromium** through the shipped sandbox proxy, connect → open app → `data-app-status="ready"` ([#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)). That last step shares its flow with `smoke:web:app` via `scripts/lib/mcp-app-flow.mjs` rather than copying it, since the deep-link shape is the part that rots silently; the *client* comes from the install while the App test server stays a repo fixture. The installed **`mcpdo`** bin gets the same treatment ([#2650](https://github.com/modelcontextprotocol/inspector/issues/2650)): the tarball must list `skills/mcpdo/SKILL.md`, which nothing reads at run time and so nothing else would miss; every bare import in `clients/mcpdo/build` must resolve from the consumer install under **ESM** rules — `import.meta.resolve` from inside the package, because `@modelcontextprotocol/ext-tasks` exports only an `import` condition and a CommonJS `require.resolve` reports it missing when it is not; and after `connect`, `@test tools/call echo` must round-trip through the packaged daemon. The daemon directory is deliberately a one-character name under the work dir, since the socket path is capped at 104 bytes on macOS and `$TMPDIR` there is already long. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate` loop or of `local:gate`. ## Cutting a release diff --git a/scripts/lib/bundle-bare-imports.mjs b/scripts/lib/bundle-bare-imports.mjs new file mode 100644 index 0000000000..0fcea55801 --- /dev/null +++ b/scripts/lib/bundle-bare-imports.mjs @@ -0,0 +1,87 @@ +/** + * Which bare specifiers a built bundle imports, and whether each one resolves + * from a given directory under **ESM** rules (#2650). + * + * `pack:verify` uses this to prove that every package the installed `mcpdo` + * bundle reaches for is actually resolvable from the consumer install — the + * failure a missing root `dependencies` entry or a dropped `external` produces, + * and one that only shows at the moment the import runs. Driving a couple of + * commands cannot see a lazy `import()` on a path those commands never take. + * + * Resolution is ESM on purpose. `@modelcontextprotocol/ext-tasks` exports only + * an `import` condition, so a CommonJS `require.resolve` reports it missing even + * though the bundle — which is ESM — loads it fine. The 2.10.0 hand check hit + * exactly that false failure. So the probe runs `import.meta.resolve` in a + * module placed **inside** the directory being checked, which is the resolver + * and the starting point the bundle itself gets. + * + * Extraction is a statement-anchored scan rather than a parser: esbuild emits + * every static import at column 0, so anchoring at the line start keeps prose + * in the bundle's comments (`… apart from "a digest was wrong" …`) from reading + * as a `from ""` clause. + */ + +import { spawnSync } from "node:child_process"; +import { rmSync, writeFileSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { join, resolve } from "node:path"; + +const STATIC_FROM = /^(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/gm; +const SIDE_EFFECT = /^import\s*["']([^"']+)["']/gm; +const DYNAMIC = /\bimport\(\s*["']([^"']+)["']\s*\)/g; + +/** True for a specifier npm has to supply: not relative, absolute or a builtin. */ +export function isBareSpecifier(specifier) { + return ( + !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.includes(":") && + !isBuiltin(specifier) + ); +} + +/** + * @param {string} source one bundle file's text + * @returns {string[]} the bare specifiers it imports, sorted and de-duplicated + */ +export function collectBareImports(source) { + const found = new Set(); + for (const pattern of [STATIC_FROM, SIDE_EFFECT, DYNAMIC]) { + for (const match of source.matchAll(pattern)) { + if (isBareSpecifier(match[1])) found.add(match[1]); + } + } + return [...found].sort(); +} + +const PROBE_NAME = "__pack_verify_resolve_probe.mjs"; +const PROBE = `const failed = []; +for (const s of JSON.parse(process.argv[2])) { + try { import.meta.resolve(s); } catch (e) { failed.push({ specifier: s, error: e.message }); } +} +process.stdout.write(JSON.stringify(failed)); +`; + +/** + * ESM-resolve each specifier from inside `dir`. The probe module is written + * into `dir` and removed again, so it resolves exactly as a module there does. + * + * @param {{ dir: string, specifiers: string[] }} options + * @returns {{ specifier: string, error: string }[]} the ones that did not resolve + */ +export function unresolvedFrom({ dir, specifiers }) { + const probe = resolve(dir, PROBE_NAME); + writeFileSync(probe, PROBE); + try { + const r = spawnSync(process.execPath, [probe, JSON.stringify(specifiers)], { + cwd: dir, + encoding: "utf8", + }); + if (r.status !== 0) { + throw new Error(`resolve probe exited ${r.status}: ${r.stderr}`); + } + return JSON.parse(r.stdout); + } finally { + rmSync(probe, { force: true }); + } +} diff --git a/scripts/lib/bundle-bare-imports.test.mjs b/scripts/lib/bundle-bare-imports.test.mjs new file mode 100644 index 0000000000..262eeeb237 --- /dev/null +++ b/scripts/lib/bundle-bare-imports.test.mjs @@ -0,0 +1,103 @@ +// Tests for `bundle-bare-imports.mjs` (#2650): the bare-specifier scan over a +// bundle, and ESM resolution from inside a directory — including the +// import-only `exports` shape a CommonJS `require.resolve` misreports. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + collectBareImports, + isBareSpecifier, + unresolvedFrom, +} from "./bundle-bare-imports.mjs"; + +test("isBareSpecifier excludes relative, absolute, URL and builtin specifiers", () => { + assert.equal(isBareSpecifier("zod"), true); + assert.equal(isBareSpecifier("@modelcontextprotocol/client/stdio"), true); + for (const s of [ + "./chunk.js", + "../x.js", + "/abs.js", + "node:fs", + "fs", + "fs/promises", + "readline/promises", + ]) { + assert.equal(isBareSpecifier(s), false, s); + } +}); + +test("collectBareImports finds static, multi-line, re-export, side-effect and dynamic imports", () => { + const source = [ + 'import { a } from "zod";', + "import {", + " b,", + " c", + '} from "@modelcontextprotocol/client/stdio";', + 'export * from "yaml";', + 'import "side-effect-pkg";', + 'import { readFileSync } from "fs";', + 'import { x } from "./chunk-ABC.js";', + 'const m = await import("undici");', + 'const n = await import("@napi-rs/keyring");', + ].join("\n"); + assert.deepEqual(collectBareImports(source), [ + "@modelcontextprotocol/client/stdio", + "@napi-rs/keyring", + "side-effect-pkg", + "undici", + "yaml", + "zod", + ]); +}); + +test('collectBareImports ignores `from "..."` prose inside a comment', () => { + const source = [ + "/**", + ' * digests to check" apart from "a digest was wrong" and "the walk', + ' * event since I last looked" from "something has", without', + " */", + 'import { z } from "zod";', + ].join("\n"); + assert.deepEqual(collectBareImports(source), ["zod"]); +}); + +test("unresolvedFrom ESM-resolves an import-only package and reports a missing one", () => { + const root = mkdtempSync(join(tmpdir(), "bare-imports-")); + try { + const pkg = join(root, "node_modules", "import-only"); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "import-only", + type: "module", + exports: { "./sub": { import: "./sub.js" } }, + }), + ); + writeFileSync(join(pkg, "sub.js"), "export {};\n"); + const dir = join(root, "consumer"); + mkdirSync(dir); + + const failed = unresolvedFrom({ + dir, + specifiers: ["import-only/sub", "not-installed"], + }); + assert.deepEqual( + failed.map((f) => f.specifier), + ["not-installed"], + ); + // The probe leaves nothing behind in the checked directory. + assert.deepEqual(readdirSync(dir), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 81b826576c..2b5a5875c9 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -22,8 +22,9 @@ * * 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,static}` - * are all present (the packaging fixes this work landed); + * asserting NO source maps ship, that `clients/web/{build,dist,static}` + * are all present (the packaging fixes this work landed), and that the + * `skills/mcpdo` agent skill ships (#2650); * 3. installs that tarball into a fresh temp dir (real `npm install `, * which runs the package's `postinstall`); * 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help @@ -32,6 +33,9 @@ * the shipped `dist` — all from the INSTALLED location, not the repo — * plus `npm exec @modelcontextprotocol/inspector --help`, so npm's own * default-bin choice is exercised and not just the bin by name (#2651); + * and the installed `mcpdo`: every bare import its bundle makes must + * ESM-resolve from inside the package, and a daemon connect must carry a + * real `tools/call` round trip (#2650); * 5. drives the **MCP Apps** path in headless Chromium against that same * installed `--web` server — connect → open app → `data-app-status="ready"` * (#2003). Asserting the sandbox proxy page merely *exists* (step 2/3) is @@ -53,7 +57,13 @@ */ import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, existsSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + existsSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -70,6 +80,10 @@ import { startMcpAppServer, } from "./lib/mcp-app-flow.mjs"; import { winShellArgs } from "./lib/win-shell-args.mjs"; +import { + collectBareImports, + unresolvedFrom, +} from "./lib/bundle-bare-imports.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const testServer = testServerEntryPath(repoRoot, "stdio"); @@ -226,22 +240,27 @@ if (maps.length > 0) { // (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.) +// `skills/mcpdo/SKILL.md` is not a runtime file, but it is a shipped one: +// the agent skill that teaches `mcpdo` is listed in "files" and nothing +// reads it at run time, so a "files" edit that dropped it would pass every +// other check here (#2650). for (const required of [ "clients/web/build/index.js", "clients/web/dist/index.html", "clients/web/static/sandbox_proxy.html", + "skills/mcpdo/SKILL.md", ]) { if (!tarredPaths.includes(required)) { fail( `expected \`${required}\` in the published tarball but it is missing — ` + - `check the "files" field in the root package.json (and that ` + - `clients/web/.npmignore does not exclude it)`, + `check the "files" field in the root package.json (and that no ` + + `.npmignore excludes it)`, ); } } console.log( `pack:verify — tarball OK: ${tarredPaths.length} files, no source maps, ` + - `clients/web/{build,dist,static} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, + `clients/web/{build,dist,static} and skills/mcpdo present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, ); // --------------------------------------------------------------------------- @@ -448,6 +467,43 @@ try { }); return { status: r.status, output: `${r.stdout ?? ""}${r.stderr ?? ""}` }; }; + + // 4b¹. Every bare import the installed mcpdo bundle makes must resolve from + // the consumer install (#2650). Driving commands only reaches the + // imports those commands load — a lazy `import()` on another path + // (`undici`, `@napi-rs/keyring`) stays untested until a user hits it. + // Resolution is ESM, from inside the package (`lib/bundle-bare-imports` + // says why a CommonJS `require.resolve` misreports `ext-tasks`). + const mcpdoBuild = join(installedPkg, "clients", "mcpdo", "build"); + const mcpdoImports = [ + ...new Set( + readdirSync(mcpdoBuild) + .filter((f) => f.endsWith(".js")) + .flatMap((f) => + collectBareImports(readFileSync(join(mcpdoBuild, f), "utf8")), + ), + ), + ].sort(); + if (mcpdoImports.length === 0) { + fail(`found no bare imports in ${mcpdoBuild} — the scan itself is broken`); + } + const unresolved = unresolvedFrom({ + dir: mcpdoBuild, + specifiers: mcpdoImports, + }); + if (unresolved.length > 0) { + fail( + `${unresolved.length} bare import(s) in the installed mcpdo bundle do not ` + + `resolve — check the root package.json "dependencies" and mcpdo's ` + + `tsup "external" list:\n ` + + unresolved.map((u) => `${u.specifier}: ${u.error}`).join("\n "), + ); + } + console.log( + `pack:verify — all ${mcpdoImports.length} bare imports in the installed ` + + `mcpdo bundle ESM-resolve from the consumer install`, + ); + const mcpdoHelp = runMcpdo(["--help"]); if (mcpdoHelp.status !== 0 || !mcpdoHelp.output.includes("Usage: mcpdo")) { fail( @@ -456,10 +512,15 @@ try { ); } // Point the daemon dir into the throwaway consumer so the check never sees - // (or touches) a real daemon on the host. + // (or touches) a real daemon on the host. The name is one character on + // purpose: the socket lives at `/mcpdod.sock`, a Unix socket path is + // capped at 104 bytes on macOS, and `work` already sits under the long + // per-user `$TMPDIR` there — a longer directory is what failed the 2.10.0 + // hand check (#2650). + const mcpdoDaemonDir = join(work, "d"); const mcpdoServers = runMcpdo( ["servers/list", "--catalog", catalogPath, "--plain"], - { MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon") }, + { MCP_INSPECTOR_DAEMON_DIR: mcpdoDaemonDir }, ); if (mcpdoServers.status !== 0 || !mcpdoServers.output.includes("test")) { fail( @@ -475,10 +536,10 @@ try { // the same stdio fixture, verify the connection is listed, then tear // everything down (stop the daemon before any fail() so nothing // outlives the check). - step("verifying installed `mcpdo` daemon flow (connect/list/disconnect)..."); - const mcpdoDaemonEnv = { - MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon"), - }; + step( + "verifying installed `mcpdo` daemon flow (connect/list/tools/call/disconnect)...", + ); + const mcpdoDaemonEnv = { MCP_INSPECTOR_DAEMON_DIR: mcpdoDaemonDir }; const stopMcpdoDaemon = () => runMcpdo(["daemon", "stop"], mcpdoDaemonEnv); const failMcpdoDaemonFlow = (message) => { stopMcpdoDaemon(); @@ -509,6 +570,23 @@ try { mcpdoConnections.output.slice(0, 800), ); } + // A method dispatched through the daemon, not just its startup: `connect` + // proves the packaged daemon runs, this proves it can carry a request from + // the installed bundle to the server and the result back (#2650). + const mcpdoCall = runMcpdo( + ["@test", "tools/call", "echo", "message:=pack-verify", "--plain"], + mcpdoDaemonEnv, + ); + if ( + mcpdoCall.status !== 0 || + !mcpdoCall.output.includes("Echo: pack-verify") + ) { + failMcpdoDaemonFlow( + `\`mcpdo @test tools/call echo\` exited ${mcpdoCall.status} or did not ` + + `echo "pack-verify"\n` + + mcpdoCall.output.slice(0, 800), + ); + } const mcpdoDisconnect = runMcpdo( ["disconnect", "test", "--plain"], mcpdoDaemonEnv, @@ -546,7 +624,8 @@ try { console.log( "\npack:verify OK — published tarball installs clean and the real bin drives " + "web (prod / served dist + an MCP App rendered through the shipped sandbox " + - "proxy), cli (stdio tools/list), and tui (help) end to end.", + "proxy), cli (stdio tools/list), tui (help), and mcpdo (daemon tools/call) " + + "end to end.", ); } catch (err) { // Unexpected throw (not via fail()) — route through fail() for consistent From d3a15b35d767a69d491be06f4660dcb0c14f0df5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 02:05:46 -0400 Subject: [PATCH 19/73] fix(remote): relay upstream HTTP status so auto era falls back on a 400 probe (#2660) The web backend's /api/mcp/send flattened every upstream transport failure to `{ kind: "transport_error", error: }`, and the browser rethrew it as a plain Error. The SDK's era-negotiation probe classifies a failed `server/discover` by HTTP status only when the error is an `SdkHttpError`, so a 2025-only server's 400 read as a network error and the connect failed instead of falling back to `initialize`. The relay now carries an `SdkHttpError`'s code, status, statusText and body as a typed `http` field, and RemoteClientTransport rebuilds the `SdkHttpError` from it. An unknown relayed code falls back to the code a non-OK POST throws with. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../remote/remoteClientTransport-unit.test.ts | 55 +++++++++++- .../integration/mcp/remote/transport.test.ts | 90 +++++++++++++++++++ core/mcp/remote/node/server.ts | 19 ++++ core/mcp/remote/remoteClientTransport.ts | 36 +++++++- core/mcp/remote/types.ts | 19 ++++ 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 51e938740c..9eb768920e 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -16,7 +16,7 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; -import { SdkErrorCode } from "@modelcontextprotocol/client"; +import { SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; import { AuthChallengeError, AuthRecoveryRequiredError, @@ -1367,6 +1367,59 @@ describe("RemoteClientTransport (focused branch coverage)", () => { await t.close(); }); + it("rebuilds an SdkHttpError from a relayed HTTP classification (#2660)", async () => { + const body = '{"jsonrpc":"2.0","error":{"code":-32000},"id":null}'; + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "Error POSTing to endpoint: bad", + http: { + code: SdkErrorCode.ClientHttpNotImplemented, + status: 400, + statusText: "Bad Request", + text: body, + }, + }), + }); + await t.start(); + const error = await t + .send({ jsonrpc: "2.0", id: 1, method: "server/discover" }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + const httpError = error as SdkHttpError; + expect(httpError.message).toBe("Error POSTing to endpoint: bad"); + expect(httpError.code).toBe(SdkErrorCode.ClientHttpNotImplemented); + expect(httpError.status).toBe(400); + expect(httpError.statusText).toBe("Bad Request"); + expect(httpError.data.text).toBe(body); + await t.close(); + }); + + it("falls back to a known code and omits absent fields on a relayed HTTP error", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "teapot", + http: { code: "NOT_A_REAL_CODE", status: 418 }, + }), + }); + await t.start(); + const error = await t + .send({ jsonrpc: "2.0", id: 1, method: "ping" }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + const httpError = error as SdkHttpError; + expect(httpError.code).toBe(SdkErrorCode.ClientHttpNotImplemented); + expect(httpError.data).toEqual({ status: 418 }); + await t.close(); + }); + it("skips cancelSseResponseWait for a notification whose send fails", async () => { const notif: JSONRPCMessage = { jsonrpc: "2.0", 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 a3e51234a0..bca02b0ace 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -15,6 +15,8 @@ import { import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createServer as createHttpServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { serve } from "@hono/node-server"; import type { ServerType } from "@hono/node-server"; import pino from "pino"; @@ -567,6 +569,94 @@ describe("Remote transport e2e", () => { await client.disconnect(); } }); + + it("auto era falls back to legacy when a 2025-only server rejects the probe with HTTP 400 (#2660)", async () => { + // A stateless 2025-only server, shaped like the v1 SDK's + // `enableJsonResponse` mode: it answers `server/discover` (sent with the + // 2026-07-28 version header) with a 400 and a JSON-RPC error body. The + // SDK's probe treats that 4xx as a legacy signal — but only if the + // relay delivers it as an `SdkHttpError` rather than a flattened string. + const methods: string[] = []; + const upstream = createHttpServer((req, res) => { + // Stateless: no standalone SSE stream (GET) and no session (DELETE). + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString()) as { + id?: number | string; + method: string; + }; + methods.push(body.method); + const reply = (status: number, payload?: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end( + payload === undefined ? undefined : JSON.stringify(payload), + ); + }; + if (body.method === "server/discover") { + reply(400, { + jsonrpc: "2.0", + error: { + code: -32000, + message: + "Bad Request: Unsupported protocol version: 2026-07-28", + }, + id: null, + }); + } else if (body.method === "initialize") { + reply(200, { + jsonrpc: "2.0", + id: body.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "v1-server", version: "1.0.0" }, + }, + }); + } else if (body.id === undefined) { + reply(202); + } else { + reply(200, { jsonrpc: "2.0", id: body.id, result: {} }); + } + }); + }); + await new Promise((resolve) => + upstream.listen(0, "127.0.0.1", resolve), + ); + const { port } = upstream.address() as AddressInfo; + + try { + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const client = new InspectorClient( + { type: "streamable-http", url: `http://127.0.0.1:${port}/mcp` }, + { + environment: { + transport: createRemoteTransport({ baseUrl, authToken }), + }, + versionNegotiation: eraToVersionNegotiation("auto"), + }, + ); + try { + await client.connect(); + expect(client.getProtocolEra()).toBe("legacy"); + expect(methods.slice(0, 2)).toEqual([ + "server/discover", + "initialize", + ]); + } finally { + await client.disconnect(); + } + } finally { + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); + } + }); }); describe("authentication", () => { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index a8b4ff8763..0c677fb367 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -36,6 +36,7 @@ import type { RemoteSetAuthStateRequest, } from "../types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; +import { SdkHttpError } from "@modelcontextprotocol/client"; import { AuthChallengeError } from "../../../auth/challenge.js"; import { parseOAuthStoreWriteBody, @@ -1110,6 +1111,24 @@ export function createRemoteApp( }); } const msg = err instanceof Error ? err.message : String(err); + // Keep an HTTP failure's classification typed on the wire, so the + // browser rebuilds an `SdkHttpError` the era probe can classify (#2660). + if (err instanceof SdkHttpError) { + const text = err.data.text; + return c.json({ + ok: false, + kind: "transport_error", + error: msg, + http: { + code: err.code, + status: err.status, + ...(err.statusText !== undefined && { + statusText: err.statusText, + }), + ...(typeof text === "string" && { text }), + }, + }); + } return c.json({ ok: false, kind: "transport_error", error: msg }); } finally { clientSignal.removeEventListener("abort", onClientDisconnect); diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index cf85c9f935..9f9a5462f2 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -11,7 +11,11 @@ import type { JSONRPCMessage, MessageExtraInfo, } from "@modelcontextprotocol/client"; -import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { + SdkError, + SdkErrorCode, + SdkHttpError, +} from "@modelcontextprotocol/client"; import { findHeader, isLongLivedStreamResponse } from "../fetchTracking.js"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; import type { FetchRequestEntryBase, FetchStreamState } from "../types.js"; @@ -30,6 +34,7 @@ import type { RemoteConnectResponse, RemoteAuthState, RemoteEvent, + RemoteHttpErrorData, RemoteSendResponse, } from "./types.js"; import { oauthTokensToRemoteAuthState } from "./types.js"; @@ -154,6 +159,32 @@ function isConnectTransportError( ); } +const SDK_ERROR_CODES: ReadonlySet = new Set( + Object.values(SdkErrorCode), +); + +/** + * Rebuild the `SdkHttpError` the backend's upstream transport threw, from the + * typed fields it relayed (#2660). The class is what matters: the SDK's + * era-negotiation probe classifies only an `SdkHttpError` by HTTP status, so a + * plain `Error` here turns a 2025-only server's 400 into a failed connect + * instead of a legacy fallback. The relayed code is backend-supplied text, so + * one this SDK does not know falls back to the code a non-OK POST throws with. + */ +function toSdkHttpError( + message: string, + http: RemoteHttpErrorData, +): SdkHttpError { + const code = SDK_ERROR_CODES.has(http.code) + ? (http.code as SdkErrorCode) + : SdkErrorCode.ClientHttpNotImplemented; + return new SdkHttpError(code, message, { + status: http.status, + ...(http.statusText !== undefined && { statusText: http.statusText }), + ...(http.text !== undefined && { text: http.text }), + }); +} + function legacySessionId(json: RemoteConnectResponse): string | undefined { if ( typeof json === "object" && @@ -821,6 +852,9 @@ export class RemoteClientTransport implements Transport { return; } + if (json.http) { + throw toSdkHttpError(json.error, json.http); + } throw new Error(json.error); } diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 661f15229c..006d16d04a 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -103,6 +103,23 @@ export type RemoteConnectResponse = | RemoteConnectResponseTransportError | { sessionId: string }; +/** + * The HTTP classification an upstream `SdkHttpError` carried, relayed so the + * browser can rebuild the same error class (#2660). The SDK's era-negotiation + * probe classifies a failed `server/discover` by HTTP status — 401/403 auth, + * 5xx abort, any other 4xx falls back to the legacy handshake — and only an + * `SdkHttpError` reaches that branch. Flattened to a message string, a 400 + * from a 2025-only server read as a network error and failed the connect. + */ +export interface RemoteHttpErrorData { + /** The `SdkErrorCode` the upstream transport threw with. */ + code: string; + status: number; + statusText?: string; + /** The upstream response body, which the probe parses for a JSON-RPC error. */ + text?: string; +} + export type RemoteSendResponse = | { ok: true } | { @@ -114,6 +131,8 @@ export type RemoteSendResponse = ok: false; kind: "transport_error"; error: string; + /** Present only when the upstream failure was an `SdkHttpError`. */ + http?: RemoteHttpErrorData; }; export interface RemoteSendRequest { From 5309dd50f5596174187ad1afe91a82d23fc21a07 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:52:19 -0400 Subject: [PATCH 20/73] fix(web): keep every monitoring-sidebar entry inside its column (#2675) A single long Protocol target (a minted resource URI) widened the whole sidebar list, clipping every card's border and pushing its controls out of view. The autosize ScrollArea's content slot sizes to its min-content, and only the Network panel opted out of that; the Protocol target's non-wrapping inner ScrollArea made the list's min-content as wide as the URI. - EmbeddableScrollArea now constrains its content to the viewport whenever it is embedded, so every sidebar tab gets the rule rather than opting in. - The compact headers of ProtocolEntry, NetworkEntry and TaskCard, and the Logs meta row, wrap their controls (or a long logger name) onto a line of their own instead of squeezing badges and buttons until their labels clip at the 320px minimum sidebar width. The Protocol target keeps a minimum scroll window, and a long subscription id scrolls in place. - Storybook play tests at the narrowest sidebar width assert that no row, card, button or label overflows, for Protocol, Network, Logs, Tasks and Console. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../EmbeddableScrollArea.test.tsx | 22 +++++ .../EmbeddableScrollArea.tsx | 25 +++-- .../components/elements/LogEntry/LogEntry.tsx | 30 ++++-- .../LogStreamPanel/LogStreamPanel.stories.tsx | 32 +++++- .../groups/NetworkEntry/NetworkEntry.tsx | 36 +++++-- .../NetworkStreamPanel.stories.tsx | 20 ++-- .../groups/ProtocolEntry/ProtocolEntry.tsx | 98 +++++++++++++++---- .../ProtocolListPanel.stories.tsx | 72 +++++++++++++- .../components/groups/TaskCard/TaskCard.tsx | 13 ++- .../TaskListPanel/TaskListPanel.stories.tsx | 27 +++++ .../ConsoleScreen/ConsoleScreen.stories.tsx | 22 +++++ .../web/src/test/narrowSidebarDecorator.tsx | 28 ++++++ .../web/src/test/scrollAreaStoryAssertions.ts | 55 +++++++++++ 13 files changed, 419 insertions(+), 61 deletions(-) create mode 100644 clients/web/src/test/narrowSidebarDecorator.tsx diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx index a718f797cc..df092b575b 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx @@ -32,6 +32,28 @@ describe("EmbeddableScrollArea", () => { expect(ref.current).toBeInstanceOf(HTMLElement); }); + it("always constrains the content width when embedded (#2675)", () => { + renderWithMantine( + +
sidebar content
+
, + ); + // No opt-in: every monitoring-sidebar panel gets the constraint, so a + // long non-wrapping row can't widen the list past the column. + const content = document.querySelector(".mantine-ScrollArea-content"); + expect((content as HTMLElement).style.minWidth).toBe("0"); + }); + + it("leaves the content width unconstrained full-size by default", () => { + renderWithMantine( + +
full-size content
+
, + ); + const content = document.querySelector(".mantine-ScrollArea-content"); + expect((content as HTMLElement).style.minWidth).toBe(""); + }); + it.each([false, true])( "constrains the content width when asked (embedded=%s)", (embedded) => { diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index ad9e87f9f8..d021ddc926 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -19,18 +19,24 @@ export interface EmbeddableScrollAreaProps { children: ReactNode; /** * Constrain the scrolled content to the viewport width instead of letting it - * grow to its own `max-content`. Mantine's ScrollArea `content` slot defaults - * to `min-width: max-content`, so a row with non-wrapping content (e.g. a long - * network URL) stretches every card past the column and it bleeds out (#1623). - * When true, the content can shrink to the viewport and each row must manage - * its own overflow (the Network URL scrolls inside its own inner ScrollArea). - * Left off for panels whose rows already wrap/truncate (Logs, Protocol), where - * the default lets a long line scroll the list horizontally instead. + * grow to its own intrinsic width. Mantine's autosize ScrollArea gives its + * `content` slot `min-width: min-content`, so a row with non-wrapping content + * (a long network URL, a long resource URI in a Protocol target) stretches + * every card past the column and it bleeds out (#1623, #2675). When + * constrained, the content can shrink to the viewport and each row must + * manage its own overflow (the Network URL and the Protocol target each + * scroll inside their own inner ScrollArea). + * + * Always on when `embedded`: nothing in a monitoring-sidebar entry may clip, + * whatever the tab, so the sidebar does not leave it to each panel to opt in + * (#2675 — Protocol never had, and clipped). This prop only matters + * full-size, where leaving it off lets a long line scroll the whole list + * horizontally instead. */ constrainContentWidth?: boolean; } -// Relax the `content` slot's default `min-width: max-content` so the list can't +// Relax the `content` slot's default intrinsic `min-width` so the list can't // grow wider than its viewport; see `constrainContentWidth`. const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const; @@ -57,7 +63,8 @@ export function EmbeddableScrollArea({ children, constrainContentWidth = false, }: EmbeddableScrollAreaProps) { - const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; + const styles = + embedded || constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; if (embedded) { return ( diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 3fb53a649a..b53472ada5 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -15,8 +15,9 @@ export interface LogEntryProps { entry: LogEntryData; /** * Compact two-line layout for the narrow monitoring sidebar (#1661): the - * timestamp, level, and logger sit on the first line and the message wraps - * onto the line below, so a long message isn't clipped by the column width. + * timestamp, level, and logger sit on the first line (a long logger wraps + * below the level) and the message wraps onto the line below, so neither is + * clipped by the column width. * The default (false) is the single-line row used on the full Logs screen. */ compact?: boolean; @@ -59,6 +60,15 @@ const LoggerText = Text.withProps({ c: "dimmed", }); +// The logger name in the compact (sidebar) row: breaks inside a long name via +// the `consoleLine` variant instead of squeezing the level badge (#2675). +const CompactLoggerText = Text.withProps({ + size: "xs", + ff: "monospace", + c: "dimmed", + variant: "consoleLine", +}); + // Single-line message (full Logs screen): sits inline with the meta on one row. const MessageText = Text.withProps({ size: "sm", @@ -73,11 +83,12 @@ const CompactMessageText = Text.withProps({ variant: "consoleLine", }); -// The compact meta row: timestamp + level + logger on one line above the -// message. `wrap: nowrap` keeps them on a single line; the message wraps below. +// The compact meta row: timestamp + level + logger above the message. It +// wraps, so a logger name too long for the line moves below the level badge +// rather than squeezing it until its label clips (#2675). const MetaRow = Group.withProps({ gap: "xs", - wrap: "nowrap", + wrap: "wrap", align: "center", }); @@ -90,9 +101,6 @@ const LogRow = Group.withProps({ export function LogEntry({ entry, compact = false }: LogEntryProps) { const { receivedAt, params } = entry; const message = formatData(params.data); - const logger = params.logger ? ( - {formatLogger(params.logger)} - ) : null; if (compact) { return ( @@ -100,7 +108,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) { {formatTimestamp(receivedAt)} - {logger} + {params.logger && ( + {formatLogger(params.logger)} + )} {formatTimestamp(receivedAt)} - {logger} + {params.logger && {formatLogger(params.logger)}} {message} diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx index a7545dc375..1002394cb5 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx @@ -3,7 +3,11 @@ import type { LoggingLevel } from "@modelcontextprotocol/client"; import { fn } from "storybook/test"; import { LogStreamPanel } from "./LogStreamPanel"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/LogStreamPanel", @@ -80,3 +84,29 @@ export const WithEntries: Story = { await expectScrollbarGutterIdleHidden(canvasElement); }, }; + +// A long unbroken token (a URI, a hash) and a long logger name in the sidebar +// must wrap or shrink inside the row, never widen the list (#2675). +export const EmbeddedLongContent: Story = { + args: { + embedded: true, + entries: [ + { + receivedAt: new Date("2026-03-17T10:00:01Z"), + params: { + level: "info", + logger: "resources.minted.gzip-file-as-resource", + data: "Minted demo://resource/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0/gzip-file-as-resource/compressed-payload.gz", + }, + }, + { + receivedAt: new Date("2026-03-17T10:00:02Z"), + params: { level: "debug", data: "short line" }, + }, + ], + }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index b19fe362e1..a1ab12fb13 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -89,16 +89,36 @@ const UrlScroll = Text.withProps({ variant: "nowrap", }); -// Left / right clusters for a compact header line (mirrors ProtocolEntry). -const HeaderCluster = Group.withProps({ +// The wide header's right-hand cluster. +const ControlsCluster = Group.withProps({ gap: "sm", wrap: "nowrap", +}); + +// The compact header's first line. It wraps, so at the narrowest sidebar +// (320px), where the badges and the duration/status cannot share one line, the +// status drops onto a line of its own instead of squeezing every badge until +// its label clips (#2675). +const CompactHeaderLine = Group.withProps({ + justify: "space-between", + wrap: "wrap", + gap: "xs", +}); + +// The compact first line's timestamp + badges. Every item is short, so the +// cluster wraps them rather than truncating the badges when they outgrow the +// line (#2675). +const CompactHeaderCluster = Group.withProps({ + gap: "sm", + wrap: "wrap", miw: 0, }); -const ControlsCluster = Group.withProps({ +// Right-aligned even when the line wraps it below the badges. +const CompactControlsCluster = Group.withProps({ gap: "sm", wrap: "nowrap", + ml: "auto", }); const DurationText = Text.withProps({ @@ -571,17 +591,17 @@ export function NetworkEntry({ {embedded ? ( // Compact two-line header for the narrow column. - - + + {formatTimestampCompact(entry.timestamp)} {phaseBadge} - - {metaBadges} - + + {metaBadges} + diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx index e006e26132..1ffa4cb9b9 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; import { NetworkStreamPanel } from "./NetworkStreamPanel"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/NetworkStreamPanel", @@ -82,14 +86,12 @@ const longUrlSample: FetchRequestEntry[] = [ ]; // Reproduces the pinned-column overflow: a long URL must scroll horizontally -// inside its own area rather than widening the card past the column (#1623). +// inside its own area rather than widening the card past the column (#1623), +// held to the sidebar's narrowest width (#2675). export const EmbeddedLongUrls: Story = { args: { entries: longUrlSample, embedded: true }, - decorators: [ - (Story) => ( -
- -
- ), - ], + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, }; diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index a6abd2e731..0ffa242d6d 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -91,17 +91,58 @@ const HeaderRow = Group.withProps({ wrap: "nowrap", }); -// Left / right clusters within a compact header line. The left cluster shrinks -// (`miw: 0`) so a long target can truncate rather than push the row wider. +// A line of the compact (sidebar) header. It wraps, so when the info cluster +// and the controls cluster don't both fit, the controls drop onto a line of +// their own rather than squeezing the badges and the target to nothing or +// running past the card's edge (#2675). The sidebar can be as narrow as 320px, +// where a method badge, a target and four controls cannot share one line. +const CompactHeaderLine = Group.withProps({ + justify: "space-between", + wrap: "wrap", + gap: "xs", +}); + +// Left / right clusters within a header line. The left cluster shrinks +// (`miw: 0`) so a long target scrolls inside it rather than pushing the row +// wider. Its basis stays `auto` — not `flex: 1`, whose zero basis would tell +// the wrapping line it always fits — so a long target sends the controls to +// the next line and then takes the full width there. const HeaderCluster = Group.withProps({ gap: "sm", wrap: "nowrap", miw: 0, }); -const ControlsCluster = Group.withProps({ +// The compact header's method + target cluster. It wraps too: a long method +// badge (`notifications/resources/updated`) leaves too little room for the +// target beside it at the narrowest sidebar, so the target takes a line of its +// own instead of being squeezed to nothing. +const GrowingHeaderCluster = Group.withProps({ + gap: "sm", + wrap: "wrap", + miw: 0, + flex: "1 1 auto", +}); + +// The target and its copy button, kept together. `flex: 1` fills whatever the +// line leaves; the `miw` is the narrowest scroll window worth showing, and is +// what makes the cluster wrap the target onto its own line rather than shrink +// it below that. +const TargetGroup = Group.withProps({ + gap: "sm", + wrap: "nowrap", + flex: 1, + miw: "6rem", +}); + +// The compact header's controls. `ml: auto` keeps them right-aligned when the +// line wraps them below the info cluster, and `miw: 0` lets a long +// subscription id among them scroll instead of overflowing the card. +const CompactControlsCluster = Group.withProps({ gap: "xs", wrap: "nowrap", + ml: "auto", + miw: 0, }); const TimestampText = Text.withProps({ @@ -140,6 +181,7 @@ const SubscriptionLabel = Text.withProps({ const SubscriptionId = Text.withProps({ size: "sm", ff: "monospace", + variant: "nowrap", }); const SubscriptionCluster = Group.withProps({ @@ -326,6 +368,26 @@ function McpSpecErrorAlert({ ); } +// The `subscriptionId` tag: label, copy button, id. In the compact sidebar +// header the id scrolls in place like the target, so a long one can't push the +// card wider than its column (#2675); the wide layout has room for it inline. +function SubscriptionTag({ + id, + scrollable, +}: { + id: string; + scrollable: boolean; +}) { + const idText = {id}; + return ( + + sub + + {scrollable ? {idText} : idText} + + ); +} + export function ProtocolEntry({ entry, isPinned, @@ -389,11 +451,7 @@ export function ProtocolEntry({ ); const subscriptionBadge = subscriptionId && ( - - sub - - {subscriptionId} - + ); const durationText = entry.duration != null && ( {formatDuration(entry.duration)} @@ -413,14 +471,14 @@ export function ProtocolEntry({ {embedded ? ( // Compact two-line header for the narrow column. - + {formatTimestampCompact(entry.timestamp)} {directionBadge} - + {durationText} {resultTypeBadge} {statusBadge} @@ -429,29 +487,29 @@ export function ProtocolEntry({ method badge on the line below gets the full column width and doesn't truncate against the pin control (#1630). */} {subscriptionBadge} - - - - + + + + {target && ( - <> + {resourceUri && } {target} - + )} - - + + {replayControls} setIsExpanded((v) => !v)} /> - - + + ) : ( <> diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx index 861f23c249..d4c0e38c1a 100644 --- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx +++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import { fn } from "storybook/test"; import { ProtocolListPanel } from "./ProtocolListPanel.js"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/ProtocolListPanel", @@ -190,3 +194,69 @@ export const ModernWithMrtr: Story = { compact: false, }, }; + +// The reported case (#2675): a `resources/read` of a minted resource whose URI +// is far wider than the sidebar, beside short entries, plus a push +// notification carrying a long subscription id. The URI must scroll +// inside its own row; no card — short ones included — may widen past the +// column, and every card's controls must stay on screen. +const LONG_URI = + "demo://resource/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0/gzip-file-as-resource/compressed-payload-with-a-very-long-name.gz"; + +const longTargetEntries: MessageEntry[] = [ + sampleEntries[0], + { + id: "req-long-uri", + timestamp: new Date("2026-03-17T10:03:00Z"), + direction: "request", + origin: "client", + message: { + jsonrpc: "2.0", + id: 5, + method: "resources/read", + params: { uri: LONG_URI }, + }, + response: { + jsonrpc: "2.0", + id: 5, + result: { contents: [{ uri: LONG_URI, text: "H4sIAAAAAAAAA..." }] }, + }, + duration: 12, + }, + sampleEntries[3], + { + id: "notif-long-sub", + timestamp: new Date("2026-03-17T10:04:00Z"), + direction: "notification", + origin: "server", + message: { + jsonrpc: "2.0", + method: "notifications/resources/updated", + params: { + uri: LONG_URI, + _meta: { + "io.modelcontextprotocol/subscriptionId": + "listen:c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0", + }, + }, + }, + }, +]; + +export const EmbeddedLongTarget: Story = { + args: { entries: longTargetEntries, embedded: true }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; + +// Same entries expanded, so the request/response JSON — which repeats the URI — +// is held to the same rule as the header. +export const EmbeddedLongTargetExpanded: Story = { + args: { entries: longTargetEntries, embedded: true, compact: false }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.tsx index 4d932e4e2a..464c39948e 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.tsx @@ -40,9 +40,14 @@ const TaskContainer = Card.withProps({ variant: "inset", }); +// Wraps, so in a column too narrow for the title, the Cancel Task control and +// the status badge all on one line (the 320px sidebar), the controls drop onto +// a line of their own instead of being squeezed until their labels clip +// (#2675). const HeaderRow = Group.withProps({ justify: "space-between", - wrap: "nowrap", + wrap: "wrap", + gap: "xs", }); const TaskIdText = Text.withProps({ @@ -88,9 +93,11 @@ const HeaderLeftGroup = Group.withProps({ const HeaderRightGroup = Group.withProps({ gap: "xs", - // Keep the Cancel Task control and the status badge together on the header's - // first row rather than letting the badge wrap beneath the button. + // Keep the Cancel Task control and the status badge together rather than + // letting the badge wrap beneath the button. wrap: "nowrap", + // Stays right-aligned when the header wraps it below the title. + ml: "auto", }); const SectionTitle = Text.withProps({ diff --git a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.stories.tsx b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.stories.tsx index 974c83913f..8f82365095 100644 --- a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.stories.tsx +++ b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Task } from "@modelcontextprotocol/client"; import { fn } from "storybook/test"; import { TaskListPanel } from "./TaskListPanel"; +import { expectNoHorizontalOverflow } from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const sampleTasks: Task[] = [ { @@ -70,3 +72,28 @@ export const Empty: Story = { tasks: [], }, }; + +// A long task id and an unbroken status message in the sidebar must truncate +// or wrap inside the card, never widen the list (#2675). +export const EmbeddedLongContent: Story = { + args: { + embedded: true, + tasks: [ + { + taskId: + "d0b22eba71fa36229ce5c4dfadeaa7ded0b22eba71fa36229ce5c4dfadeaa7de", + status: "working", + ttl: 300000, + createdAt: "2026-03-29T20:18:20Z", + lastUpdatedAt: "2026-03-29T20:18:22Z", + statusMessage: + "Reading demo://resource/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0/gzip-file-as-resource/compressed-payload.gz", + }, + sampleTasks[2], + ], + }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx index 85dd5fdbac..70bf4f389d 100644 --- a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx +++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx @@ -5,6 +5,8 @@ import { expect, fn, userEvent, within } from "storybook/test"; import type { StderrLogEntry } from "../../../../../../core/mcp/types.js"; import { ConsoleScreen } from "./ConsoleScreen"; import { EMPTY_CONSOLE_UI } from "../screenUiState"; +import { expectNoHorizontalOverflow } from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; // ConsoleScreen is controlled (the search text lives in the parent as `ui` — // see #1417). This wrapper holds that state so the play-driven search actually @@ -85,3 +87,23 @@ export const FilterBySearch: Story = { ).toBeInTheDocument(); }, }; + +// A long unbroken stderr token in the sidebar must wrap inside the line, never +// widen the list (#2675). +export const EmbeddedLongContent: Story = { + args: { + embedded: true, + entries: [ + ...sampleEntries, + { + timestamp: new Date("2026-03-17T10:00:02Z"), + message: + "FileNotFoundError: /usr/local/lib/python3.12/site-packages/mcp_server_time/resources/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0.gz", + }, + ], + }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/test/narrowSidebarDecorator.tsx b/clients/web/src/test/narrowSidebarDecorator.tsx new file mode 100644 index 0000000000..a40d41908f --- /dev/null +++ b/clients/web/src/test/narrowSidebarDecorator.tsx @@ -0,0 +1,28 @@ +import type { Decorator } from "@storybook/react-vite"; + +// The monitoring sidebar's narrowest width: the divider clamps it to 320px +// (`inspector.monitor.width`, 320–720). A story that must not clip is held to +// the strictest case rather than the 420px default. +export const NARROWEST_SIDEBAR_WIDTH_PX = 320; + +// A fixed-height flex host, like the sidebar column, so an `embedded` panel +// fills it and its list scrolls instead of growing the story. +const SIDEBAR_HEIGHT_PX = 600; + +/** + * Hosts a story inside a box the shape of the monitoring sidebar at its + * narrowest, for the stories asserting that nothing in a sidebar entry clips + * (#2675). A raw `div` with inline style is deliberate: this is test harness, + * not app UI, and it stands in for the sidebar's own sized column. + */ +export const narrowSidebarDecorator: Decorator = (Story) => ( +
+ +
+); diff --git a/clients/web/src/test/scrollAreaStoryAssertions.ts b/clients/web/src/test/scrollAreaStoryAssertions.ts index b3de5bbfb1..3017bb05eb 100644 --- a/clients/web/src/test/scrollAreaStoryAssertions.ts +++ b/clients/web/src/test/scrollAreaStoryAssertions.ts @@ -27,3 +27,58 @@ export async function expectScrollbarGutterIdleHidden( await expect(bar.getAttribute("data-state")).toBe("hidden"); } } + +// Sub-pixel tolerance for layout comparisons: rects are fractional and the two +// edges are rounded independently, so an exact `<=` can fail on a fit. +const LAYOUT_EPSILON_PX = 1; + +/** + * Assert nothing in a list `ScrollArea` overflows its viewport horizontally: + * the viewport does not scroll sideways, every row, card and button ends + * inside it, and no button or badge label is cut short. Used by the monitoring-sidebar stories, + * where a single long entry once widened the whole list and clipped every card + * at the column's edge (#1623, #2675). It needs real layout, so it only means + * anything in a browser — happy-dom reports zero for every rect. + */ +export async function expectNoHorizontalOverflow(canvasElement: HTMLElement) { + const viewport = canvasElement.querySelector(".mantine-ScrollArea-viewport"); + if (!(viewport instanceof HTMLElement)) { + throw new Error("scroll viewport not found"); + } + await expect(viewport.scrollWidth).toBeLessThanOrEqual( + viewport.clientWidth + LAYOUT_EPSILON_PX, + ); + // The content's right edge is the viewport's minus its scrollbar gutter, so + // measure against the viewport's content box rather than its border box. + const box = viewport.getBoundingClientRect(); + const right = + box.right - parseFloat(getComputedStyle(viewport).paddingInlineEnd); + // The list's own rows (the children of its top-level Stack — a Logs or + // Console line, a Protocol section) and every card nested anywhere in it. + const rows = viewport.querySelectorAll( + ".mantine-ScrollArea-content > * > *, .mantine-Card-root", + ); + // Guard against a trivially-true loop if the list renders no rows at all. + await expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + await expect(row.getBoundingClientRect().right).toBeLessThanOrEqual( + right + LAYOUT_EPSILON_PX, + ); + } + for (const button of viewport.querySelectorAll("button")) { + const rect = button.getBoundingClientRect(); + // A button inside a collapsed section has no box; it can't clip. + if (rect.width === 0) continue; + await expect(rect.right).toBeLessThanOrEqual(right + LAYOUT_EPSILON_PX); + } + // A box that fits can still clip its own text: a squeezed button or badge + // hides the overflow of its label. Every label must show all of its text. + for (const label of viewport.querySelectorAll( + ".mantine-Button-label, .mantine-Badge-label", + )) { + if (label.clientWidth === 0) continue; + await expect(label.scrollWidth).toBeLessThanOrEqual( + label.clientWidth + LAYOUT_EPSILON_PX, + ); + } +} From 1b9f154a9f90782ad1e0782ebd31e6e44f3bca47 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 02:36:04 -0400 Subject: [PATCH 21/73] test(web): fail a rendered control squeezed to zero width (#2675) The overflow helper skipped any zero-width button or label, so a control flexbox crushed to nothing passed. Skip only elements with no client rects (not rendered, e.g. a collapsed section) and require a rendered button to have width. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../web/src/test/scrollAreaStoryAssertions.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/scrollAreaStoryAssertions.ts b/clients/web/src/test/scrollAreaStoryAssertions.ts index 3017bb05eb..9e5c19749b 100644 --- a/clients/web/src/test/scrollAreaStoryAssertions.ts +++ b/clients/web/src/test/scrollAreaStoryAssertions.ts @@ -32,6 +32,13 @@ export async function expectScrollbarGutterIdleHidden( // edges are rounded independently, so an exact `<=` can fail on a fit. const LAYOUT_EPSILON_PX = 1; +// Whether an element generates a box at all. `display: none` (a collapsed +// section) yields no client rects, while a flex item squeezed to zero width +// still has one — so this tells "not shown" apart from "shown but crushed". +function isRendered(element: Element): boolean { + return element.getClientRects().length > 0; +} + /** * Assert nothing in a list `ScrollArea` overflows its viewport horizontally: * the viewport does not scroll sideways, every row, card and button ends @@ -66,9 +73,12 @@ export async function expectNoHorizontalOverflow(canvasElement: HTMLElement) { ); } for (const button of viewport.querySelectorAll("button")) { + // Only a button that isn't rendered at all (inside a collapsed section) + // is exempt. A rendered one squeezed to zero width is the very collapse + // this helper exists to catch, so it must fail rather than be skipped. + if (!isRendered(button)) continue; const rect = button.getBoundingClientRect(); - // A button inside a collapsed section has no box; it can't clip. - if (rect.width === 0) continue; + await expect(rect.width).toBeGreaterThan(0); await expect(rect.right).toBeLessThanOrEqual(right + LAYOUT_EPSILON_PX); } // A box that fits can still clip its own text: a squeezed button or badge @@ -76,7 +86,7 @@ export async function expectNoHorizontalOverflow(canvasElement: HTMLElement) { for (const label of viewport.querySelectorAll( ".mantine-Button-label, .mantine-Badge-label", )) { - if (label.clientWidth === 0) continue; + if (!isRendered(label)) continue; await expect(label.scrollWidth).toBeLessThanOrEqual( label.clientWidth + LAYOUT_EPSILON_PX, ); From 61b81c51643276ac658a8f2243c9e5d38d2abc8b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:43:21 -0400 Subject: [PATCH 22/73] test(web): isolate integration OAuth state from ~/.mcp-inspector (#2670) The integration project now runs a setup file that points MCP_INSPECTOR_OAUTH_STATE_PATH at a per-test-file temp directory, so suites that use the default NodeOAuthStorage / clearAllOAuthClientState no longer read, rewrite or clear the developer's real oauth.json, and no longer share one file across workers and concurrent gates. A runtime assertion in storage.test.ts fails if the default store ever resolves back under the home directory. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../integration/auth/node/storage.test.ts | 10 +++++ .../web/src/test/integrationStorageSetup.ts | 43 +++++++++++++++++++ clients/web/vite.config.ts | 8 +++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/test/integrationStorageSetup.ts diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 08d849dd84..170f36078a 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -577,6 +577,16 @@ describe("NodeOAuthStorage with custom storagePath", () => { } }); + it("resolves the default store outside the developer's home directory", () => { + // The default-store cases below write to and clear whatever + // getStateFilePath() resolves. integrationStorageSetup.ts points it at a + // temp directory (#2670); if that ever stops holding, fail here rather + // than clear a real ~/.mcp-inspector/storage/oauth.json. + const resolved = getStateFilePath(); + expect(resolved.startsWith(os.tmpdir())).toBe(true); + expect(resolved).not.toContain(path.join(".mcp-inspector", "storage")); + }); + it("clearAllOAuthClientState clears every server in the default store", async () => { resetNodeOAuthStorageCache(); const storage = new NodeOAuthStorage(); diff --git a/clients/web/src/test/integrationStorageSetup.ts b/clients/web/src/test/integrationStorageSetup.ts new file mode 100644 index 0000000000..9eacd30d47 --- /dev/null +++ b/clients/web/src/test/integrationStorageSetup.ts @@ -0,0 +1,43 @@ +/** + * Keeps the integration project off the developer's real OAuth state (#2670). + * + * Several suites construct `NodeOAuthStorage()` or call + * `clearAllOAuthClientState()` with no path — `storage.test.ts`, the OAuth + * e2e suites, and everything built on `helpers/oauth-client-fixtures.ts`. With + * no override those resolve to `~/.mcp-inspector/storage/oauth.json`, so a + * `local:gate` run rewrote — and `clearAllOAuthClientState()` emptied — the + * stored OAuth state of whoever ran it. It also made every forks-pool worker, + * every concurrent worktree's gate and any running Inspector share one file, + * which is how "clears every server in the default store" flaked on #2630. + * + * `MCP_INSPECTOR_OAUTH_STATE_PATH` is the per-file override `getStateFilePath()` + * reads at call time, ahead of `MCP_STORAGE_DIR` and the default. It is set + * unconditionally rather than only when absent: a value inherited from the + * developer's shell is exactly the real file this exists to keep tests away + * from. A setup file runs once per test file, so each file gets its own + * directory — isolation between files in one worker as well as between + * workers. Suites that test resolution itself save, delete and restore the + * variable around each case, which this does not constrain. + * + * `MCP_STORAGE_DIR` is deliberately left alone: tests that exercise it + * (secret-store selection, web server config) set and restore it themselves, + * and pinning it here would change what they observe. + */ + +import { afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const isolatedStorageDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mcp-inspector-it-oauth-"), +); + +process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join( + isolatedStorageDir, + "oauth.json", +); + +afterAll(() => { + fs.rmSync(isolatedStorageDir, { recursive: true, force: true }); +}); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 0678e242f3..c079c57f40 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -374,7 +374,13 @@ export default defineConfig(({ command }) => { // can transform core/ modules and run tests against the source. root: repoRoot, include: [integrationGlob], - setupFiles: [NO_RETRY_SETUP], + // Points the default OAuth state file at a per-test-file temp + // directory, so no suite reads or rewrites the developer's real + // `~/.mcp-inspector/storage/oauth.json` (#2670). + setupFiles: [ + path.join(dirname, "src/test/integrationStorageSetup.ts"), + NO_RETRY_SETUP, + ], // Integration tests spawn real HTTP/stdio servers via test-servers/, // bind sockets, run e2e OAuth flows, and exercise filesystem-backed // storage. 30s matches the v1.5 core/vitest.config.ts. Now stated From 847538485030040b627801947c995e56ca84c121 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:51:45 -0400 Subject: [PATCH 23/73] test(web): make the default-store isolation check a beforeEach guard (#2670) A failed standalone it() does not stop the destructive default-store cases after it, so a broken setup could still let clearAllOAuthClientState() clear a real oauth.json. A throwing beforeEach fails every case before its body runs. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../integration/auth/node/storage.test.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 170f36078a..5124d42b1e 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -22,6 +22,26 @@ const testStatePath = path.join( `mcp-inspector-oauth-${process.pid}-storage-node.json`, ); +// Several cases below write to and clear whatever getStateFilePath() +// resolves. integrationStorageSetup.ts points it at a temp directory (#2670). +// A guard, not a test: a failed `it` would not stop the destructive cases +// after it, but a throwing `beforeEach` fails every case before its body runs, +// so a broken setup can never clear a real ~/.mcp-inspector/storage/oauth.json. +// It runs before any nested `beforeEach`, so the resolution suite's own +// unsetting of the variable does not trip it. +beforeEach(() => { + const resolved = getStateFilePath(); + if ( + !resolved.startsWith(os.tmpdir()) || + resolved.includes(path.join(".mcp-inspector", "storage")) + ) { + throw new Error( + `Default OAuth store resolves to ${resolved}, outside the test temp ` + + `directory; integrationStorageSetup.ts is not in effect (#2670).`, + ); + } +}); + async function resetTestStorage(stateFilePath: string): Promise { try { await fs.unlink(stateFilePath); @@ -577,16 +597,6 @@ describe("NodeOAuthStorage with custom storagePath", () => { } }); - it("resolves the default store outside the developer's home directory", () => { - // The default-store cases below write to and clear whatever - // getStateFilePath() resolves. integrationStorageSetup.ts points it at a - // temp directory (#2670); if that ever stops holding, fail here rather - // than clear a real ~/.mcp-inspector/storage/oauth.json. - const resolved = getStateFilePath(); - expect(resolved.startsWith(os.tmpdir())).toBe(true); - expect(resolved).not.toContain(path.join(".mcp-inspector", "storage")); - }); - it("clearAllOAuthClientState clears every server in the default store", async () => { resetNodeOAuthStorageCache(); const storage = new NodeOAuthStorage(); From 10cb1b7d402e1add3ff38f0a54ec027c5ff0a4e5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:02:34 -0400 Subject: [PATCH 24/73] fix(web): re-read the viewed skill file on a skills/get fetch (#2516) The Skills screen read SKILL.md once per manifest, so a server that changed the file after selection was never re-asked: the viewer kept the first resources/read until a reconnect. Fetch with skills/get now also re-reads the file the viewer is showing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 52 +++++++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 23 +++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 247b2d43b0..54d1a04573 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -826,6 +826,58 @@ describe("SkillsScreen", () => { ); }); + it("re-reads the viewer's SKILL.md on a skills/get fetch (#2516)", async () => { + // The automatic read fires once per manifest, so a server that changed the + // file after the selection was never re-asked: the viewer kept the first + // `resources/read` until a reconnect. + const user = userEvent.setup(); + let served = "# first\n"; + const onReadSkillFile = vi.fn(async () => ({ + text: served, + mimeType: "text/markdown", + })); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const viewer = screen.getByTestId("skill-resource-viewer"); + await waitFor(() => expect(viewer).toHaveTextContent("first")); + + served = "# second\n"; + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + await waitFor(() => expect(viewer).toHaveTextContent("second")); + expect(onReadSkillFile).toHaveBeenCalledTimes(2); + expect(onReadSkillFile).toHaveBeenLastCalledWith(CLEAN_SKILL.uri); + await settle(); + }); + + it("re-reads the supporting file the viewer shows on a skills/get fetch (#2516)", async () => { + // The fetch refreshes whatever the reader is looking at, not the SKILL.md + // they navigated away from. + const user = userEvent.setup(); + const onReadSkillFile = vi.fn((uri: string) => readFixtureFile(uri)); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { + name: "skill://data-analysis/reference.md", + }), + ); + await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(2)); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(3)); + expect(onReadSkillFile).toHaveBeenLastCalledWith( + "skill://data-analysis/reference.md", + ); + await settle(); + }); + it("treats key and manifest order as immaterial when matching", async () => { // The manifest is a set and JSON key order carries no meaning, so a server // that enumerates either differently is not inconsistent — a diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 5159b16c11..ccd800d22e 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -1325,7 +1325,28 @@ export function SkillsScreen({ message: errorMessage(err), }); }); - }, [manifestKey, onGetSkill, openConformance, selected]); + // A fresh snapshot of the entry is also a fresh read of the file in the + // viewer (#2516). The automatic read fires once per manifest, so without + // this the viewer kept the selection's FIRST `resources/read` for as long + // as the listing stayed the same — a server that changed the file was + // never re-asked short of a reconnect, and the fetched entry was shown + // beside bytes from before it. Issued after `skills/get`, so the Protocol + // tab lists the two in the order the gesture implies. + const viewing = + (previewState.key === key ? previewState.uri : undefined) ?? selected.uri; + showResource( + viewing, + key, + skillUriIdentity(viewing) === skillUriIdentity(selected.uri), + ); + }, [ + manifestKey, + onGetSkill, + openConformance, + previewState, + selected, + showResource, + ]); const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined; // `invalid` outranks the snapshot comparison: an entry that breaks a From 4914f25d85b501ea8fa4376a24a74a43e2fd8d90 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:03:37 -0400 Subject: [PATCH 25/73] fix(auth): apply endpoint overrides when the AS publishes no metadata (#2562) When authorization-server discovery found no metadata document, the SDK fell back to /authorize and /token on the server's origin and the configured Authorization/Token URL overrides had nothing to patch, so a gateway fronting an external AS (Entra ID) could not be authorized. withOAuthEndpointOverrides now answers a failed final discovery candidate with a minimal RFC 8414 document carrying the overrides (and the SDK's own /authorize, /token, /register fallbacks for anything not overridden), issuer set to the AS URL being discovered. The compat wrapper hands it to discovery without attributing it to the server, and the response carries x-inspector-oauth-metadata-synthesized. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 217 ++++++++++++++++++ core/auth/endpointOverrides.ts | 164 ++++++++++++- core/auth/oidcDiscoveryCompat.ts | 16 ++ docs/mcp-server-configuration.md | 2 +- 4 files changed, 391 insertions(+), 8 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 19fbe266c3..1536ffd60a 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -1,11 +1,23 @@ import { describe, it, expect, vi, afterEach } from "vitest"; +import { + buildDiscoveryUrls, + discoverAuthorizationServerMetadata, + startAuthorization, +} from "@modelcontextprotocol/client"; import { applyOAuthEndpointOverrides, + authorizationServerForFinalCandidate, + SYNTHESIZED_METADATA_HEADER, + synthesizeAuthorizationServerMetadata, isAuthorizationServerMetadata, normalizeOAuthEndpointOverrides, oauthEndpointUrlError, withOAuthEndpointOverrides, } from "@inspector/core/auth/endpointOverrides.js"; +import { + COMPAT_SOURCE_HEADER, + withRfc8414OidcCompat, +} from "@inspector/core/auth/oidcDiscoveryCompat.js"; const METADATA = { issuer: "https://as.example.com", @@ -392,3 +404,208 @@ describe("withOAuthEndpointOverrides", () => { expect(response.headers.get("content-type")).toBe("application/json"); }); }); + +// #2562: an authorization server that publishes no metadata at all. The SDK +// would fall back to `/authorize` and `/token` on its origin, so without a +// synthesized document the overrides had nothing to patch and were ignored. +describe("authorizationServerForFinalCandidate", () => { + // The "last candidate" rule is a hand-written reading of the SDK's + // `buildDiscoveryUrls`; pinning it against the SDK's own export keeps the two + // from drifting if the SDK changes its candidate list. + it.each([ + "https://gateway.example.com", + "https://gateway.example.com/", + "https://gateway.example.com/tenant", + "https://gateway.example.com/tenant/", + ])("identifies only the SDK's last candidate for %s", (asUrl) => { + const candidates = buildDiscoveryUrls(asUrl).map((c) => c.url.href); + const matched = candidates.map(authorizationServerForFinalCandidate); + expect(matched.slice(0, -1)).toEqual( + candidates.slice(0, -1).map(() => undefined), + ); + expect(matched.at(-1)).toBe(asUrl.replace(/\/$/, "")); + }); + + it("rejects a URL that will not parse", () => { + expect(authorizationServerForFinalCandidate("not a url")).toBeUndefined(); + }); +}); + +describe("synthesizeAuthorizationServerMetadata", () => { + it("uses the overrides and keeps the SDK's fallbacks for the rest", () => { + expect( + synthesizeAuthorizationServerMetadata("https://gateway.example.com/t", { + authorizationUrl: STAGING_AUTHORIZE, + }), + ).toEqual({ + issuer: "https://gateway.example.com/t", + authorization_endpoint: STAGING_AUTHORIZE, + token_endpoint: "https://gateway.example.com/token", + registration_endpoint: "https://gateway.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }); + }); + + it("uses the token override with the authorize fallback", () => { + expect( + synthesizeAuthorizationServerMetadata("https://gateway.example.com", { + tokenUrl: STAGING_TOKEN, + }), + ).toMatchObject({ + authorization_endpoint: "https://gateway.example.com/authorize", + token_endpoint: STAGING_TOKEN, + }); + }); +}); + +describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => { + const FINAL_CANDIDATE = + "https://gateway.example.com/.well-known/openid-configuration"; + const OVERRIDES = { + authorizationUrl: STAGING_AUTHORIZE, + tokenUrl: STAGING_TOKEN, + }; + + function silenceWarn() { + return vi.spyOn(console, "warn").mockImplementation(() => {}); + } + + it("answers a failed final candidate with a synthesized document", async () => { + const warn = silenceWarn(); + const wrapped = withOAuthEndpointOverrides( + passThrough(new Response("nope", { status: 404 })), + () => OVERRIDES, + ); + const response = await wrapped(FINAL_CANDIDATE); + expect(response.status).toBe(200); + expect(response.headers.get(SYNTHESIZED_METADATA_HEADER)).toBe( + "endpoint-overrides", + ); + await expect(response.json()).resolves.toMatchObject({ + issuer: "https://gateway.example.com", + authorization_endpoint: STAGING_AUTHORIZE, + token_endpoint: STAGING_TOKEN, + }); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("treats a 502 like a 404, as the SDK does", async () => { + silenceWarn(); + const wrapped = withOAuthEndpointOverrides( + passThrough(new Response("bad gateway", { status: 502 })), + () => OVERRIDES, + ); + await expect(wrapped(FINAL_CANDIDATE)).resolves.toHaveProperty( + "status", + 200, + ); + }); + + it("leaves a 500 alone — the SDK surfaces it as an outage", async () => { + const original = new Response("boom", { status: 500 }); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => OVERRIDES, + ); + await expect(wrapped(FINAL_CANDIDATE)).resolves.toBe(original); + }); + + it("does not synthesize on an earlier candidate", async () => { + const original = new Response("nope", { status: 404 }); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => OVERRIDES, + ); + // Root RFC 8414 candidate: the OIDC candidate after it may still answer. + await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original); + }); + + it("does not synthesize for a non-GET request", async () => { + const original = new Response("nope", { status: 404 }); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => OVERRIDES, + ); + await expect( + wrapped(new Request(FINAL_CANDIDATE, { method: "POST" })), + ).resolves.toBe(original); + await expect(wrapped(FINAL_CANDIDATE, { method: "post" })).resolves.toBe( + original, + ); + }); + + it("recognizes a GET Request object", async () => { + silenceWarn(); + const wrapped = withOAuthEndpointOverrides( + passThrough(new Response("nope", { status: 404 })), + () => OVERRIDES, + ); + await expect(wrapped(new Request(FINAL_CANDIDATE))).resolves.toHaveProperty( + "status", + 200, + ); + }); + + it("does nothing when no override is configured", async () => { + const original = new Response("nope", { status: 404 }); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => undefined, + ); + await expect(wrapped(FINAL_CANDIDATE)).resolves.toBe(original); + }); + + // The compat wrapper hands the synthesized document to discovery, but must + // not attribute it to the server or warn that the server returned it. + it("passes through the compat wrapper as synthesized, not as the server's", async () => { + const warn = silenceWarn(); + const fetchFn = withRfc8414OidcCompat( + withOAuthEndpointOverrides( + async () => new Response("nope", { status: 404 }), + () => OVERRIDES, + ), + ); + const response = await fetchFn(AS_METADATA_URL); + expect(response.status).toBe(200); + expect(response.headers.get(SYNTHESIZED_METADATA_HEADER)).toBe( + "endpoint-overrides", + ); + expect(response.headers.get(COMPAT_SOURCE_HEADER)).toBeNull(); + // Only the overrides wrapper's own warning — not the compat one. + expect(warn).toHaveBeenCalledOnce(); + expect(String(warn.mock.calls[0][0])).toContain( + "publishes no authorization server metadata", + ); + }); + + // The issue's reproduction, through the SDK's own discovery and + // authorization-URL construction: a server with no metadata anywhere, and + // the compat wrapper outermost exactly as `InspectorClient` composes it. + it.each(["https://gateway.example.com/", "https://gateway.example.com/api"])( + "sends the SDK's authorization request to the override for %s", + async (asUrl) => { + silenceWarn(); + const fetchFn = withRfc8414OidcCompat( + withOAuthEndpointOverrides( + async () => new Response("nope", { status: 404 }), + () => OVERRIDES, + ), + ); + const metadata = await discoverAuthorizationServerMetadata(asUrl, { + fetchFn, + }); + expect(metadata?.token_endpoint).toBe(STAGING_TOKEN); + const { authorizationUrl } = await startAuthorization(asUrl, { + metadata, + clientInformation: { client_id: "client-123" }, + redirectUrl: "http://127.0.0.1:6274/oauth/callback", + scope: "api://app/scope", + }); + expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe( + STAGING_AUTHORIZE, + ); + expect(authorizationUrl.searchParams.get("client_id")).toBe("client-123"); + }, + ); +}); diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index c2438b3088..b7e880d9d2 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -24,12 +24,25 @@ * * Four consequences worth knowing: * - * - **A metadata document is required.** When discovery returns nothing, the SDK - * falls back to `/authorize` and `/token` on the authorization server's origin - * and there is no document to patch. That matches the feature as asked for — - * overriding "whatever urls the authorization server returns" — and a server - * publishing no metadata is already on a path where the endpoints are not - * being advertised in the first place. + * - **No metadata document means one is synthesized (#2562).** When discovery + * finds nothing, the SDK falls back to `/authorize` and `/token` on the + * authorization server's origin — so a gateway that publishes no metadata and + * fronts an external authorization server (Entra ID, say) could never be + * pointed at it, which is exactly when an override is most needed. So when + * the *last* discovery candidate fails the way the SDK walks past, and an + * override is configured, the wrapper answers it with a minimal document: + * the configured endpoints, and for anything not overridden the very + * `/authorize`, `/token` and `/register` fallbacks the SDK would have used. + * Its `issuer` is the authorization-server URL the SDK was discovering, so + * the issuer-echo check and the callback binding see what they would have + * recorded with no document at all. The response is stamped with + * {@link SYNTHESIZED_METADATA_HEADER} so the Network tab does not present it + * as the server's own answer. ⚠️ The last candidate is always an + * `openid-configuration` URL, which the SDK validates as an OpenID provider + * document; the synthesized one is plain RFC 8414 (inventing `jwks_uri` + * would be fabricating the server's keys), so it reaches the SDK through + * `withRfc8414OidcCompat`, which hands it to discovery as the RFC 8414 + * document it is. `InspectorClient` composes the two in that order. * - **They do not apply to the enterprise-managed (EMA) leg.** That flow * authorizes against the enterprise IdP — a different authorization server — * and its OIDC discovery runs through this same fetch, so `OAuthManager` @@ -262,6 +275,138 @@ function isJsonDocumentResponse(contentType: string | null): boolean { return mediaType === "application/json" || mediaType.endsWith("+json"); } +/** + * Response header stamped on a metadata document this module synthesized + * because the authorization server published none (#2562). Inspector-private + * (`x-inspector-`), never sent on a request — it exists so a captured Network + * entry does not read as a 200 the server returned. + */ +export const SYNTHESIZED_METADATA_HEADER = + "x-inspector-oauth-metadata-synthesized"; + +/** The OpenID Connect Discovery well-known path. */ +const OIDC_WELL_KNOWN = "/.well-known/openid-configuration"; + +/** + * Whether a status makes the SDK move on to the next discovery candidate — + * a 4xx or a 502, mirroring `discoverAuthorizationServerMetadata`. Anything + * else the SDK throws on, and a synthesized document must not paper over an + * outage the SDK would have surfaced. + */ +function continuesDiscovery(status: number): boolean { + return (status >= 400 && status < 500) || status === 502; +} + +/** + * The authorization-server URL a request was the **final** discovery candidate + * for, or `undefined` when it is not one. + * + * The SDK's `buildDiscoveryUrls` always ends its list with the path-appended + * OIDC form, `${path}/.well-known/openid-configuration` (just + * `/.well-known/openid-configuration` for an authorization server at the + * origin), and no earlier candidate ends that way: the RFC 8414 form and the + * path-inserted OIDC form both carry the authorization server's path *after* + * the well-known segment. So "the pathname ends with the OIDC well-known path" + * identifies the last candidate, and stripping that suffix recovers the + * authorization server it was built from. Synthesizing anywhere earlier would + * pre-empt a document a later candidate actually serves. + */ +export function authorizationServerForFinalCandidate( + url: string, +): string | undefined { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return undefined; + } + if (!parsed.pathname.endsWith(OIDC_WELL_KNOWN)) return undefined; + const path = parsed.pathname.slice(0, -OIDC_WELL_KNOWN.length); + return `${parsed.origin}${path}`; +} + +/** + * A minimal RFC 8414 metadata document for an authorization server that + * publishes none, carrying the configured overrides (#2562). + * + * Every endpoint not overridden is the fallback the SDK itself uses when + * discovery returns nothing (`startAuthorization`, `executeTokenRequest`, + * `registerClient`: `/authorize`, `/token`, `/register` resolved against the + * authorization server URL), so overriding one endpoint changes that endpoint + * and nothing else. `registration_endpoint` is required for that: with a + * document present the SDK refuses dynamic registration unless the document + * advertises it, where with none it would have tried `/register`. + */ +export function synthesizeAuthorizationServerMetadata( + issuer: string, + overrides: OAuthEndpointOverrides, +): Record { + return { + issuer, + authorization_endpoint: + overrides.authorizationUrl ?? new URL("/authorize", issuer).href, + token_endpoint: overrides.tokenUrl ?? new URL("/token", issuer).href, + registration_endpoint: new URL("/register", issuer).href, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; +} + +/** + * The effective HTTP method of a `fetch` call, normalized. `fetch` defaults to + * `GET` and treats the method case-insensitively. + */ +function requestMethodOf( + input: RequestInfo | URL, + init: RequestInit | undefined, +): string { + if (init?.method) return init.method.toUpperCase(); + if (typeof input !== "string" && !(input instanceof URL)) { + return input.method.toUpperCase(); + } + return "GET"; +} + +/** + * The synthesized-document response for a failed final discovery candidate, + * or `undefined` when this failure is not one discovery would walk past. + */ +function synthesizedMetadataResponse( + input: RequestInfo | URL, + init: RequestInit | undefined, + response: Response, + overrides: OAuthEndpointOverrides, +): Response | undefined { + if (!continuesDiscovery(response.status)) return undefined; + // Discovery is a `GET`; an endpoint that merely lives under a well-known + // path answering some other method is not ours to replace. + if (requestMethodOf(input, init) !== "GET") return undefined; + const issuer = authorizationServerForFinalCandidate(requestUrlOf(input)); + if (!issuer) return undefined; + + // The failed response is dropped, so release its connection. Detached: the + // cancel is permitted never to settle, and nothing here may wait on it — + // the `void` is the callee-owns-its-failures case, since the `.catch` + // swallows a body already consumed or locked. + void response.body?.cancel().catch(() => {}); + console.warn( + `[oauth] ${issuer} publishes no authorization server metadata; using ` + + `the configured endpoint override(s) in place of the SDK's ` + + `/authorize and /token fallbacks.`, + ); + return new Response( + JSON.stringify(synthesizeAuthorizationServerMetadata(issuer, overrides)), + { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + [SYNTHESIZED_METADATA_HEADER]: "endpoint-overrides", + }, + }, + ); +} + /** Rebuild a response around a replacement body, preserving status/headers. */ function responseWithBody(response: Response, body: string): Response { const headers = new Headers(response.headers); @@ -316,7 +461,12 @@ export function withOAuthEndpointOverrides( // Cheapest tests first: only a discovery request can carry the document // this wrapper rewrites, so ordinary traffic never reaches the clone below. if (!isAuthorizationServerMetadataRequest(input)) return response; - if (!response.ok) return response; + if (!response.ok) { + return ( + synthesizedMetadataResponse(input, init, response, overrides) ?? + response + ); + } if (!isJsonDocumentResponse(response.headers.get("content-type"))) return response; diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index e92a1971de..b4c9cdf54c 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -91,6 +91,7 @@ */ import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js"; +import { SYNTHESIZED_METADATA_HEADER } from "./endpointOverrides.js"; import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, @@ -395,6 +396,21 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // reason as the non-OK probe above (#2389): the substitution must not // wait on a cancel that may never settle. releaseBodyDetached(response); + // A document `withOAuthEndpointOverrides` synthesized for a server that + // publishes none (#2562) is not something `source` returned, so it is + // neither warned about nor attributed to it — it keeps its own marker, + // and the overrides wrapper has already said what it did. + const synthesized = probe.headers.get(SYNTHESIZED_METADATA_HEADER); + if (synthesized) { + return new Response(body, { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + [SYNTHESIZED_METADATA_HEADER]: synthesized, + }, + }); + } console.warn( `[oauth] ${source} returned RFC 8414 OAuth 2.0 authorization server ` + `metadata, not an OpenID provider document. The MCP TypeScript SDK ` + diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index f4918bfbe7..76675b0bec 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -257,7 +257,7 @@ They redirect the **endpoints**, not the authorization server's identity: `issue They are **not applied under enterprise-managed authorization** (`oauth.enterpriseManaged`), for the same reason `oauth.authorizationParams` isn't: that flow authorizes against the enterprise IdP — a different authorization server, whose OIDC discovery would otherwise be rewritten too, pointing the IdP login (or the IdP code exchange) at the resource server's authorization server. -> Because the override is applied to the _discovered metadata document_, a server whose authorization server publishes no metadata at all is unaffected — there the SDK falls back to `/authorize` and `/token` on the AS origin, and there is nothing to override. +> The overrides also apply when the authorization server publishes **no metadata at all** — for example a gateway that fronts an external authorization server such as Entra ID. Without them the SDK would fall back to `/authorize` and `/token` on the AS origin; with either set, the Inspector supplies a minimal metadata document in their place carrying the configured endpoint(s), keeping those same fallbacks (plus `/register`) for anything left blank. Its `issuer` is the AS URL discovery was probing, exactly as when no document exists. The Network tab marks that document with an `x-inspector-oauth-metadata-synthesized` response header so it is not mistaken for the server's own answer. A catalog carrying these fields: From 4fdf2d65bfa6581c94df8deda4a03fa6f51f08b3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:06:29 -0400 Subject: [PATCH 26/73] fix(tui): Esc clears a kept list filter before exiting (#2647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a `/` filter was kept with Enter, the next Esc fell through to App's exit accelerator and quit the TUI. A kept filter now claims Esc while its list has the keyboard: the first Esc clears it, the second exits. useListFilter reports the claim through a new onEscapeClaimChange effect (withdrawn on clear, focus loss, modal or unmount, like onEditingChange), the four list tabs pass it through, and App skips exit() only for Esc — the other accelerators keep working on the narrowed list. The `?` help and the kept-filter hint say so. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/App.test.tsx | 18 ++++++ clients/tui/__tests__/ListFilterBar.test.tsx | 8 +++ clients/tui/__tests__/ToolsTab.test.tsx | 2 +- clients/tui/__tests__/useListFilter.test.tsx | 67 +++++++++++++++++++- clients/tui/src/App.tsx | 12 +++- clients/tui/src/components/ListFilterBar.tsx | 7 +- clients/tui/src/components/PromptsTab.tsx | 4 ++ clients/tui/src/components/ResourcesTab.tsx | 4 ++ clients/tui/src/components/SkillsTab.tsx | 4 ++ clients/tui/src/components/ToolsTab.tsx | 4 ++ clients/tui/src/hooks/useListFilter.ts | 39 ++++++++++-- clients/tui/src/utils/keybindings.ts | 7 +- 12 files changed, 163 insertions(+), 13 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 6d70a59252..befee2605d 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -1138,6 +1138,24 @@ describe("App (status, layout, modals)", () => { expect(h.disconnect).toHaveBeenCalled(); }); + it("clears a kept list filter on Esc instead of exiting (#2647)", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [ + sampleTool, + { ...sampleTool, name: "dpt-tool", description: "D" }, + ]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, "/", "d", "p", "t", ENTER]); + await expectFrame(r, "Tools (1/2)"); + // The first Esc clears the kept filter; the app is still running and + // answering its accelerators. + await press(r, [ESC]); + await expectFrame(r, "Tools (2)"); + await press(r, ["d"]); + await waitUntil(() => h.disconnect.mock.calls.length > 0); + expect(h.disconnect).toHaveBeenCalled(); + }); + it("types '?' into a list filter instead of opening the help (#2430 + #2436)", async () => { h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; diff --git a/clients/tui/__tests__/ListFilterBar.test.tsx b/clients/tui/__tests__/ListFilterBar.test.tsx index 4064dac5c0..e45401691d 100644 --- a/clients/tui/__tests__/ListFilterBar.test.tsx +++ b/clients/tui/__tests__/ListFilterBar.test.tsx @@ -12,6 +12,14 @@ describe("ListFilterBar", () => { expect(lastFrame()).toContain("Enter keep · Esc clear"); }); + it("tells a focused list that Esc clears a kept query (#2647)", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain("/echo"); + expect(lastFrame()).toContain("(/ edit · Esc clear)"); + }); + it("shows a kept query and how to change it", () => { const { lastFrame } = render( , diff --git a/clients/tui/__tests__/ToolsTab.test.tsx b/clients/tui/__tests__/ToolsTab.test.tsx index b5a39cbc0e..15bbd36a05 100644 --- a/clients/tui/__tests__/ToolsTab.test.tsx +++ b/clients/tui/__tests__/ToolsTab.test.tsx @@ -326,7 +326,7 @@ describe("ToolsTab list filter (#2430)", () => { stdin.write(DOWN); await tick(); frame = lastFrame() ?? ""; - expect(frame).toContain("(/ to edit)"); + expect(frame).toContain("(/ edit · Esc clear)"); expect(frame).toContain("▶ delta"); // A query nothing matches. diff --git a/clients/tui/__tests__/useListFilter.test.tsx b/clients/tui/__tests__/useListFilter.test.tsx index c3e89bc660..7430266602 100644 --- a/clients/tui/__tests__/useListFilter.test.tsx +++ b/clients/tui/__tests__/useListFilter.test.tsx @@ -35,13 +35,19 @@ const fields = (item: Item) => [item.name, item.title]; function Harness({ enabled = true, onEditingChange, + onEscapeClaimChange, onUnconsumed, }: { enabled?: boolean; onEditingChange?: (editing: boolean) => void; + onEscapeClaimChange?: (claimed: boolean) => void; onUnconsumed?: (input: string) => void; }) { - const filter = useListFilter(items, fields, { enabled, onEditingChange }); + const filter = useListFilter(items, fields, { + enabled, + onEditingChange, + onEscapeClaimChange, + }); useInput((input, key) => { if (!filter.handleInput(input, key)) onUnconsumed?.(input); }); @@ -106,6 +112,65 @@ describe("useListFilter", () => { expect(lastFrame()).toContain("items=alpha,beta,gamma"); }); + it("clears a kept filter on Esc, claiming Esc only while one is kept (#2647)", async () => { + const onEscapeClaimChange = vi.fn(); + const onUnconsumed = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + await tick(); + // Editing is App's whole-keyboard mute, not the Esc claim. + await type(stdin, ["/", "b"]); + expect(onEscapeClaimChange).not.toHaveBeenCalled(); + + await type(stdin, [ENTER]); + expect(lastFrame()).toContain("editing=false"); + expect(lastFrame()).toContain("items=beta"); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(true); + + // The first Esc is the filter's: it clears the query and withdraws. + await type(stdin, [ESC]); + expect(lastFrame()).toContain("q=[]"); + expect(lastFrame()).toContain("items=alpha,beta,gamma"); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(false); + expect(onUnconsumed).not.toHaveBeenCalled(); + + // The second has nothing to clear, so it is left to the app. + await type(stdin, [ESC]); + expect(onUnconsumed).toHaveBeenCalledTimes(1); + }); + + it("withdraws the Esc claim while the list lacks the keyboard", async () => { + const onEscapeClaimChange = vi.fn(); + const onUnconsumed = vi.fn(); + const { lastFrame, stdin, rerender } = render( + , + ); + await tick(); + await type(stdin, ["/", "b", ENTER]); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(true); + + rerender( + , + ); + await tick(); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(false); + // The filter is kept, not cleared, and Esc is no longer its to take. + await type(stdin, [ESC]); + expect(lastFrame()).toContain("q=[b]"); + expect(onUnconsumed).toHaveBeenCalledTimes(1); + }); + it("leaves navigation and unrelated keys to the list", async () => { const onUnconsumed = vi.fn(); const { lastFrame, stdin } = render( diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 754cd859e0..1edca7f6a0 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -191,6 +191,10 @@ function App({ // global accelerators below stand down then, or typing a query would switch // tabs, connect, or quit on Esc. Reported by `useListFilter`. const [listFilterEditing, setListFilterEditing] = useState(false); + // True while a kept (non-empty, no longer edited) list filter has the + // keyboard (#2647). Esc then clears the filter instead of quitting; only the + // exit key stands down, since a kept filter exists so the rest still work. + const [listFilterClaimsEscape, setListFilterClaimsEscape] = useState(false); const [tabCounts, setTabCounts] = useState<{ info?: number; resources?: number; @@ -1639,8 +1643,10 @@ function App({ return; } - // Exit accelerators + // Exit accelerators. A kept list filter takes Esc first — the tab clears + // it — so quitting is the second press (#2647). if (key.escape) { + if (listFilterClaimsEscape) return; exit(); } @@ -2209,6 +2215,7 @@ function App({ / {query} - (/ to edit) + + {focused ? " (/ edit · Esc clear)" : " (/ to edit)"} + ) : focused ? ( / to filter diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index c43008bdb4..6392ab06e6 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -30,6 +30,8 @@ interface PromptsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** What the `/` filter matches a prompt against (#2430). */ @@ -48,11 +50,13 @@ export function PromptsTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: PromptsTabProps) { const visibleCount = Math.max(1, height - 7 - LIST_FILTER_ROWS); const filter = useListFilter(prompts, promptFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownPrompts = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index 689c4babf5..6c0ce35297 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -40,6 +40,8 @@ interface ResourcesTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } type ResourceListItem = @@ -72,6 +74,7 @@ export function ResourcesTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: ResourcesTabProps) { const [error, setError] = useState(null); const [resourceContent, setResourceContent] = @@ -99,6 +102,7 @@ export function ResourcesTab({ const filter = useListFilter(allItems, resourceFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownItems = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/components/SkillsTab.tsx b/clients/tui/src/components/SkillsTab.tsx index 3eb3863f97..7966f630b0 100644 --- a/clients/tui/src/components/SkillsTab.tsx +++ b/clients/tui/src/components/SkillsTab.tsx @@ -69,6 +69,8 @@ interface SkillsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** @@ -252,12 +254,14 @@ export function SkillsTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: SkillsTabProps) { // One more row than the bare list: the catalog summary line (#2590). const visibleCount = Math.max(1, height - 8 - LIST_FILTER_ROWS); const filter = useListFilter(skills, skillFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownSkills = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/components/ToolsTab.tsx b/clients/tui/src/components/ToolsTab.tsx index 5bab4c5f23..55c1bb27a5 100644 --- a/clients/tui/src/components/ToolsTab.tsx +++ b/clients/tui/src/components/ToolsTab.tsx @@ -56,6 +56,8 @@ interface ToolsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** What the `/` filter matches a tool against (#2430). */ @@ -73,11 +75,13 @@ export function ToolsTab({ onViewDetails, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: ToolsTabProps) { const visibleCount = Math.max(1, height - 7 - LIST_FILTER_ROWS); const filter = useListFilter(tools, toolFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownTools = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/hooks/useListFilter.ts b/clients/tui/src/hooks/useListFilter.ts index 9cbf7ebf82..978f7002e1 100644 --- a/clients/tui/src/hooks/useListFilter.ts +++ b/clients/tui/src/hooks/useListFilter.ts @@ -14,9 +14,10 @@ * the list as you type; * - **Enter keeps** the query and leaves editing, so the arrows, Enter and the * app-wide accelerators work on the narrowed list; - * - **Esc clears** the query and leaves editing. A kept filter is cleared the - * same way — `/` then Esc — because a bare Esc outside editing is the app's - * exit key and stays that. + * - **Esc clears** the query and leaves editing. Esc also clears a **kept** + * filter while the list has the keyboard (#2647): a user who has just kept + * a filter expects Esc to undo it, not to quit, so Esc is the app's exit key + * only once no filter is left to clear — the second press. * * Up/Down are deliberately **not** consumed while editing, so the selection can * be moved without leaving the query. @@ -31,6 +32,11 @@ * focus or a modal opening (`enabled` going false), or the tab unmounting — * and App can never be left deaf to the keyboard. * + * A kept filter mutes App's Esc alone, not the other accelerators — the whole + * point of keeping it is that they work on the narrowed list. That is a second, + * narrower report, `onEscapeClaimChange`, withdrawn the same way: once the + * query is cleared, or the pane loses the keyboard, Esc exits again. + * * A match is a case-insensitive substring of any of the strings `fields` * returns for an item. `fields` is called during render, so it must be pure; * declare it at module scope so the memoized result is not rebuilt every @@ -58,6 +64,13 @@ export interface UseListFilterOptions { * setter is), since it is an effect dependency. */ onEditingChange?: (editing: boolean) => void; + /** + * Told whenever a kept (non-editing, non-empty) filter starts or stops + * claiming Esc while the list has the keyboard, so App can hold off its exit + * key until the filter is cleared (#2647). Must be referentially stable, + * like `onEditingChange`. + */ + onEscapeClaimChange?: (claimed: boolean) => void; } export interface ListFilter { @@ -90,7 +103,7 @@ function printable(input: string): string { export function useListFilter( items: readonly T[], fields: ListFilterFields, - { enabled, onEditingChange }: UseListFilterOptions, + { enabled, onEditingChange, onEscapeClaimChange }: UseListFilterOptions, ): ListFilter { const [query, setQuery] = useState(""); const [editingRequested, setEditingRequested] = useState(false); @@ -104,6 +117,16 @@ export function useListFilter( return () => onEditingChange?.(false); }, [editing, onEditingChange]); + const active = query.trim() !== ""; + // While editing, App is already muted entirely; this covers the kept filter. + const claimsEscape = enabled && active && !editingRequested; + + useEffect(() => { + if (!claimsEscape) return; + onEscapeClaimChange?.(true); + return () => onEscapeClaimChange?.(false); + }, [claimsEscape, onEscapeClaimChange]); + const { filtered, indices } = useMemo(() => { const needle = query.trim().toLowerCase(); const filtered: T[] = []; @@ -128,6 +151,10 @@ export function useListFilter( setEditingRequested(true); return true; } + if (key.escape && query.trim() !== "") { + setQuery(""); + return true; + } return false; } if (key.escape) { @@ -152,13 +179,13 @@ export function useListFilter( if (text) setQuery((prev) => prev + text); return true; }, - [enabled, editingRequested], + [enabled, editingRequested, query], ); return { query, editing, - active: query.trim() !== "", + active, items: filtered, indices, handleInput, diff --git a/clients/tui/src/utils/keybindings.ts b/clients/tui/src/utils/keybindings.ts index ca42c9cb4e..a62fd6c680 100644 --- a/clients/tui/src/utils/keybindings.ts +++ b/clients/tui/src/utils/keybindings.ts @@ -46,7 +46,10 @@ export function globalBindings( ): readonly KeyBinding[] { return [ { keys: "?", action: "Show or hide this help" }, - { keys: "Esc / Ctrl+C", action: "Exit (Esc closes a dialog first)" }, + { + keys: "Esc / Ctrl+C", + action: "Exit (Esc closes a dialog or clears a kept filter first)", + }, { keys: "Tab / Shift+Tab", action: "Move focus: servers → tabs → list → details", @@ -68,7 +71,7 @@ const DETAILS_SCROLL: readonly KeyBinding[] = [ const LIST_FILTER: KeyBinding = { keys: "/", - action: "Filter the list (Enter keeps it, Esc clears it)", + action: "Filter the list (Enter keeps it; Esc clears it, kept or not)", }; const PANE_SCROLL: readonly KeyBinding[] = [ From cd9cb16873d500de2a0b9a0bafe90c2c41b82edb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:06:52 -0400 Subject: [PATCH 27/73] fix(web): print a one-line warning when the browser opener cannot start (#2645) openBrowser logged the whole error object, so a missing opener printed a stack plus spawnargs carrying the tokenized launch URL. Print the message only, in the CLI's wording. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/web/server/web-server-config.ts | 9 +++++- .../server/server-auto-open.test.ts | 28 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index d8f8a24f5a..29ca4c1515 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -267,7 +267,14 @@ export function openBrowser(url: string): void { // event on the child, which no `.catch` here could see and which crashed // the server at startup (#2533). `openUrl` turns it into a rejection. openUrl(url).catch((err: unknown) => { - console.warn("Could not open a browser automatically:", err); + // The message only, on one line, matching the CLI's wording + // (`core/cli/cli-oauth-navigation.ts`). Logging the error object printed + // a stack plus `spawnargs`, which carry the tokenized launch URL — a + // second copy of the token for anyone pasting the error (#2645). + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `Could not open a browser automatically (${reason}). Open the URL above manually.`, + ); }); } diff --git a/clients/web/src/test/integration/server/server-auto-open.test.ts b/clients/web/src/test/integration/server/server-auto-open.test.ts index c820081389..4f061abf38 100644 --- a/clients/web/src/test/integration/server/server-auto-open.test.ts +++ b/clients/web/src/test/integration/server/server-auto-open.test.ts @@ -29,7 +29,9 @@ vi.mock("open", () => ({ default: openMock })); const { openBrowser } = await import("../../../../server/web-server-config.js"); const { startHonoServer } = await import("../../../../server/server.js"); -const WARNING = "Could not open a browser automatically:"; +/** The one-line warning for `reason`: the message only, never the error object (#2645). */ +const warning = (reason: string) => + `Could not open a browser automatically (${reason}). Open the URL above manually.`; /** * A stand-in for the `ChildProcess` `open` resolves with: like a real spawn it @@ -80,20 +82,34 @@ describe("openBrowser", () => { process.off("unhandledRejection", onUnhandled); } expect(unhandled).toEqual([]); - expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error)); + expect(warn).toHaveBeenCalledWith(warning("no browser on this host")); + }); + + it("reports a non-Error rejection by its string form", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + openMock.mockRejectedValue("no opener"); + openBrowser("http://127.0.0.1:6274"); + await settle(); + expect(warn).toHaveBeenCalledWith(warning("no opener")); }); it("warns instead of crashing when the opener cannot be spawned", async () => { // `open` resolves first and the ENOENT arrives afterwards as an 'error' // event on the child; unlistened, that event throws and kills the server. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Shaped like the real error: `spawnargs` carries the tokenized URL, + // which must not reach the terminal a second time (#2645). + const url = "http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=secret-token"; const enoent = Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT", + spawnargs: [url], }); openMock.mockImplementation(async () => fakeChild(enoent)); - expect(openBrowser("http://127.0.0.1:6274")).toBeUndefined(); + expect(openBrowser(url)).toBeUndefined(); await settle(); - expect(warn).toHaveBeenCalledWith(WARNING, enoent); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(warning("spawn xdg-open ENOENT")); + expect(String(warn.mock.calls[0][0])).not.toContain("secret-token"); }); it("stays quiet when the browser opens", async () => { @@ -102,7 +118,7 @@ describe("openBrowser", () => { openBrowser("http://127.0.0.1:6274"); await settle(); expect(openMock).toHaveBeenCalledWith("http://127.0.0.1:6274"); - expect(warn).not.toHaveBeenCalledWith(WARNING, expect.anything()); + expect(warn).not.toHaveBeenCalled(); }); }); @@ -159,7 +175,7 @@ describe("startHonoServer autoOpen", () => { expect(Number(opened.port)).toBeGreaterThan(0); const baseUrl = opened.origin; expect(unhandled).toEqual([]); - expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error)); + expect(warn).toHaveBeenCalledWith(warning("no browser on this host")); // The point of warning rather than throwing: the server is still up. const res = await fetch(`${baseUrl}/`); expect(res.status).toBe(200); From 545df79f26c7354ea2a646218674ea56a87ab279 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:11:24 -0400 Subject: [PATCH 28/73] fix(auth): don't treat a plain 403 without WWW-Authenticate as an OAuth challenge (#2515) parseAuthChallengeFromResponse classified every 403 as an `unauthorized` challenge, so a server that denied one operation (e.g. subscriptions/listen) with a bare 403 sent the web client into protected-resource discovery, AS discovery and client registration against a server with no authorization server. A 403 is now a challenge only when it carries WWW-Authenticate; a bare 403 is returned to the MCP caller as an ordinary denial. 401s and 403 insufficient_scope challenges are unchanged. The intercept/observer fetch wrappers' `v8 ignore` claims that the undefined branch was unreachable are replaced with tests that reach it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../web/src/test/core/auth/challenge.test.ts | 23 +++++++++++++++ .../core/mcp/node/authChallengeFetch.test.ts | 29 +++++++++++++++++++ core/auth/challenge.ts | 13 ++++++++- core/mcp/inspectorClient.ts | 7 +++-- core/mcp/node/authChallengeFetch.ts | 5 ++-- 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/auth/challenge.test.ts b/clients/web/src/test/core/auth/challenge.test.ts index 08c7a68ac5..14ad1a2500 100644 --- a/clients/web/src/test/core/auth/challenge.test.ts +++ b/clients/web/src/test/core/auth/challenge.test.ts @@ -181,6 +181,29 @@ describe("parseAuthChallengeFromResponse", () => { "unauthorized", ); }); + + // #2515: a 403 without WWW-Authenticate is an ordinary denial of the + // operation, not an OAuth challenge. + it("returns undefined for a 403 without WWW-Authenticate", () => { + const response = new Response(null, { status: 403 }); + expect(parseAuthChallengeFromResponse(response)).toBeUndefined(); + }); + + it("returns undefined for a 403 with an empty WWW-Authenticate", () => { + const response = new Response(null, { + status: 403, + headers: { "WWW-Authenticate": "" }, + }); + expect(parseAuthChallengeFromResponse(response)).toBeUndefined(); + }); + + it("still treats a 401 without WWW-Authenticate as a challenge", () => { + const response = new Response(null, { status: 401 }); + expect(parseAuthChallengeFromResponse(response)).toEqual({ + reason: "token_expired", + raw: { httpStatus: 401 }, + }); + }); }); describe("parseAuthChallengeFromError", () => { diff --git a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts index 4dbfc46c37..8ac8f05b42 100644 --- a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts +++ b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts @@ -86,6 +86,20 @@ describe("createAuthChallengeInterceptFetch", () => { AuthChallengeError, ); }); + + // #2515: a bare 403 is a denial — it must reach the MCP caller as-is, with + // its body intact, rather than start OAuth recovery. + it("passes through a 403 without WWW-Authenticate untouched", async () => { + const baseFetch = vi.fn( + async () => new Response("forbidden", { status: 403 }), + ); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + + const res = await fetchFn("https://example.com/mcp"); + + expect(res.status).toBe(403); + expect(await res.text()).toBe("forbidden"); + }); }); describe("createAuthChallengeObserverFetch", () => { @@ -134,6 +148,21 @@ describe("createAuthChallengeObserverFetch", () => { ); }); + it("stays silent on a 403 without WWW-Authenticate", async () => { + const baseFetch = vi.fn( + async () => new Response("forbidden", { status: 403 }), + ); + const onChallenge = vi.fn(); + + const res = await createAuthChallengeObserverFetch( + baseFetch, + onChallenge, + )("https://example.com/mcp"); + + expect(res.status).toBe(403); + expect(onChallenge).not.toHaveBeenCalled(); + }); + it("stays silent on a non-challenge response", async () => { const baseFetch = vi.fn(async () => new Response("ok", { status: 200 })); const onChallenge = vi.fn(); diff --git a/core/auth/challenge.ts b/core/auth/challenge.ts index 78092a9b55..5f7ee5e9bc 100644 --- a/core/auth/challenge.ts +++ b/core/auth/challenge.ts @@ -291,6 +291,14 @@ function reasonFromHttpResponse( /** * Build an AuthChallenge from an MCP HTTP response (401 / 403). * Returns undefined when the response is not an auth challenge. + * + * A 403 is a challenge only when it carries `WWW-Authenticate`. A bare 403 is + * an ordinary denial of that one operation — the server understood the + * credentials and refuses anyway — so treating it as a challenge started OAuth + * discovery and client registration against servers with no authorization + * server at all (#2515). Left undefined, the 403 reaches the MCP caller as a + * normal error. A bare 401 is still a challenge: 401 says the credentials + * themselves were not accepted. */ export function parseAuthChallengeFromResponse( response: Response, @@ -301,7 +309,10 @@ export function parseAuthChallengeFromResponse( return undefined; } - const wwwAuthenticate = response.headers.get("WWW-Authenticate") ?? undefined; + const wwwAuthenticate = response.headers.get("WWW-Authenticate") || undefined; + if (status === 403 && !wwwAuthenticate) { + return undefined; + } const bearer = wwwAuthenticate ? parseWwwAuthenticateBearer(wwwAuthenticate) : {}; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index bee4faeb27..3ecabfeade 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1845,9 +1845,10 @@ export class InspectorClient extends InspectorClientEventTarget { // for that upstream gap and should be deleted with it. // // Known, accepted side effect of turning intercept on with no stored - // tokens: `parseAuthChallengeFromResponse` treats 403 as a challenge - // too, so a probe answered 403 for a *non-auth* reason (a gateway - // rejecting the unknown `server/discover` method, say) now starts OAuth + // tokens: `parseAuthChallengeFromResponse` treats a 403 carrying + // `WWW-Authenticate` as a challenge too, so a probe answered that way + // for a *non-auth* reason (a gateway rejecting the unknown + // `server/discover` method, say) now starts OAuth // discovery instead of letting "auto" fall back to the legacy // `initialize`. The outcome is a surfaced `oauthError`, not a hang, and // it goes away with this clause. diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts index 1ed8e34aa3..bc63d40070 100644 --- a/core/mcp/node/authChallengeFetch.ts +++ b/core/mcp/node/authChallengeFetch.ts @@ -18,7 +18,8 @@ export function createAuthChallengeInterceptFetch( } const challenge = parseAuthChallengeFromResponse(response); - /* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */ + // A 403 with no `WWW-Authenticate` is an ordinary denial, not a challenge + // (#2515): hand it to the transport untouched so the MCP caller sees it. if (!challenge) { return response; } @@ -63,7 +64,7 @@ export function createAuthChallengeObserverFetch( const response = await baseFetch(input, init); if (response.status === 401 || response.status === 403) { const challenge = parseAuthChallengeFromResponse(response); - /* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */ + // Undefined for a bare 403, which is a denial, not a challenge (#2515). if (challenge) { onChallenge(challenge); } From ec9e57aea8160ad0cd6728f5f0d88b6acb2266f6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:15:09 -0400 Subject: [PATCH 29/73] docs(auth): narrow fetch-wrapper JSDoc to the challenges they act on (#2515) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- core/mcp/node/authChallengeFetch.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts index bc63d40070..67e12875fc 100644 --- a/core/mcp/node/authChallengeFetch.ts +++ b/core/mcp/node/authChallengeFetch.ts @@ -5,8 +5,10 @@ import { import type { AuthChallenge } from "../../auth/challenge.js"; /** - * Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError} - * before the SDK invokes `auth()` on a frozen remote token provider. + * Wrap fetch so MCP HTTP auth challenges — any 401, and a 403 carrying + * `WWW-Authenticate` — become {@link AuthChallengeError} before the SDK invokes + * `auth()` on a frozen remote token provider. A bare 403 is a denial, not a + * challenge, and is returned as-is (#2515). */ export function createAuthChallengeInterceptFetch( baseFetch: typeof fetch, @@ -40,8 +42,9 @@ export function createAuthChallengeInterceptFetch( } /** - * Wrap fetch so every MCP HTTP 401/403 is *reported* — control flow untouched, - * response returned as-is. + * Wrap fetch so every MCP HTTP auth challenge (as classified by + * `parseAuthChallengeFromResponse` — a bare 403 is not one, #2515) is + * *reported* — control flow untouched, response returned as-is. * * This is the passive sibling of {@link createAuthChallengeInterceptFetch}, * and it exists for the one case that one cannot cover: a first-time From 5e2f0de161fbd717e271e2ba762051faa9f96231 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:20:48 -0400 Subject: [PATCH 30/73] fix(tui): delete copy-to-file temp files when the TUI exits (#2648) The W fallback writes the copied value (possibly an access token) to $TMPDIR/mcp-inspector-copy-*/value.txt and never removed it. Track every directory writeCopyFile creates and remove them synchronously from a process exit listener; every TUI exit path ends in process.exit. The status line now says the file is deleted on exit, and the README notes it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/README.md | 2 +- clients/tui/__tests__/clipboard.test.ts | 58 +++++++++++++++++++++- clients/tui/__tests__/useCopyKeys.test.tsx | 4 +- clients/tui/src/hooks/useCopyKeys.ts | 5 +- clients/tui/src/utils/clipboard.ts | 39 ++++++++++++++- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index 876ba5a773..8bb5cf0f40 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -103,7 +103,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se Any details view (opened from Resources, Prompts, Tools, Protocol or Network) and the **Auth** tab's access token can be copied out of the TUI ([#2421](https://github.com/modelcontextprotocol/inspector/issues/2421)): - **Y** copies the value to your clipboard with an [OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) escape sequence. The sequence asks the _terminal emulator_ to set its clipboard, so it reaches the machine you are sitting at even when the TUI runs over SSH. A details view copies the entry's raw JSON; the Auth tab copies the full token, not the truncated prefix it displays. -- **W** saves the value to a private temp file (`mcp-inspector-copy-*/value.txt`, mode `0600`) and shows its path — the fallback when OSC 52 does not work. +- **W** saves the value to a private temp file (`mcp-inspector-copy-*/value.txt`, mode `0600`) and shows its path — the fallback when OSC 52 does not work. The file is deleted when the TUI exits, since it may hold a token; copy it somewhere else first if you want to keep it. OSC 52 is fire-and-forget: a terminal that does not support it (or has it disabled) fails silently, which is why the status line after **Y** points at **W**. Most modern terminals support it — iTerm2 (enable _Applications in terminal may access clipboard_), kitty, WezTerm, Alacritty, Windows Terminal, foot, and xterm with `allowWindowOps`. Inside **tmux**, set `set -g set-clipboard on` so tmux forwards the sequence. Some terminals cap the payload (often around 100 KB); a copy above that is flagged in the status line, and **W** has no such limit. diff --git a/clients/tui/__tests__/clipboard.test.ts b/clients/tui/__tests__/clipboard.test.ts index 965070f72a..e0a664b519 100644 --- a/clients/tui/__tests__/clipboard.test.ts +++ b/clients/tui/__tests__/clipboard.test.ts @@ -1,11 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OSC52_LARGE_PAYLOAD_BYTES, buildOsc52Sequence, copyViaOsc52, + removeCopyFiles, toCopyText, writeCopyFile, } from "../src/utils/clipboard.js"; @@ -61,6 +62,7 @@ describe("copyViaOsc52", () => { describe("writeCopyFile", () => { const created: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of created.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -83,4 +85,58 @@ describe("writeCopyFile", () => { created.push(path.dirname(file)); expect(file.startsWith(os.tmpdir())).toBe(true); }); + + it("removes every directory it created on removeCopyFiles (#2648)", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + const first = writeCopyFile("token-a", base); + const second = writeCopyFile("token-b", base); + removeCopyFiles(); + expect(fs.existsSync(path.dirname(first))).toBe(false); + expect(fs.existsSync(path.dirname(second))).toBe(false); + // The base directory is not ours, so it survives. + expect(fs.existsSync(base)).toBe(true); + // A second sweep has nothing left to remove and does not throw. + expect(() => removeCopyFiles()).not.toThrow(); + }); + + it("registers the cleanup as a single process exit listener", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + writeCopyFile("a", base); + writeCopyFile("b", base); + const listeners = process + .listeners("exit") + .filter((l) => l === removeCopyFiles); + expect(listeners).toHaveLength(1); + removeCopyFiles(); + }); + + it("still cleans up a directory whose file write failed", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + vi.spyOn(fs, "writeFileSync").mockImplementationOnce(() => { + throw new Error("disk full"); + }); + expect(() => writeCopyFile("v", base)).toThrow("disk full"); + expect(fs.readdirSync(base)).toHaveLength(1); + removeCopyFiles(); + expect(fs.readdirSync(base)).toHaveLength(0); + }); + + it("keeps going when a directory cannot be removed", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + const first = writeCopyFile("a", base); + const second = writeCopyFile("b", base); + const realRm = fs.rmSync; + vi.spyOn(fs, "rmSync").mockImplementationOnce(() => { + throw new Error("EBUSY"); + }); + expect(() => removeCopyFiles()).not.toThrow(); + // The first removal threw; the second still ran. + expect(fs.existsSync(path.dirname(first))).toBe(true); + expect(fs.existsSync(path.dirname(second))).toBe(false); + realRm(path.dirname(first), { recursive: true, force: true }); + }); }); diff --git a/clients/tui/__tests__/useCopyKeys.test.tsx b/clients/tui/__tests__/useCopyKeys.test.tsx index 997c2e9f80..8751b99ca1 100644 --- a/clients/tui/__tests__/useCopyKeys.test.tsx +++ b/clients/tui/__tests__/useCopyKeys.test.tsx @@ -88,7 +88,9 @@ describe("useCopyKeys", () => { // Compare with all whitespace removed, so a wrap anywhere in the line — // including mid-path — cannot fail the check. expect(squashWhitespace(lastFrame() ?? "")).toContain( - squashWhitespace(`[success] Saved thing to ${file}`), + squashWhitespace( + `[success] Saved thing to ${file} (deleted when the TUI exits)`, + ), ); }); diff --git a/clients/tui/src/hooks/useCopyKeys.ts b/clients/tui/src/hooks/useCopyKeys.ts index 6e5631939d..9ee8e2267e 100644 --- a/clients/tui/src/hooks/useCopyKeys.ts +++ b/clients/tui/src/hooks/useCopyKeys.ts @@ -57,7 +57,10 @@ export function useCopyKeys(value: string | undefined, label: string) { if (key === SAVE_KEY) { try { const file = writeCopyFile(value); - setStatus({ tone: "success", message: `Saved ${label} to ${file}` }); + setStatus({ + tone: "success", + message: `Saved ${label} to ${file} (deleted when the TUI exits)`, + }); } catch (err) { setStatus({ tone: "error", diff --git a/clients/tui/src/utils/clipboard.ts b/clients/tui/src/utils/clipboard.ts index 3c93335d06..08ddd89954 100644 --- a/clients/tui/src/utils/clipboard.ts +++ b/clients/tui/src/utils/clipboard.ts @@ -14,6 +14,14 @@ // for that case is `writeCopyFile`, which saves the raw value to a private temp // file whose path the TUI then shows — readable with `cat` or `scp` from // anywhere, and not bounded by the terminal's line width or scrollback. +// +// That file may hold a bearer token, so it does not outlive the TUI (#2648): +// every directory `writeCopyFile` creates is removed by a process `exit` +// listener. Every way out of the TUI ends in `process.exit` (`tui.tsx` calls it +// once Ink unmounts), so the listener runs on Esc, Ctrl+C and a render error +// alike. A process killed by a signal it does not handle runs no `exit` +// listeners and leaves the file behind — still owner-only, in its 0700 +// directory, which is why the status line names the path either way. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -82,17 +90,46 @@ export function copyViaOsc52(text: string, sink: EscapeSink): Osc52CopyResult { }; } +/** Directories `writeCopyFile` created that have not been removed yet. */ +const copyDirs = new Set(); +let exitCleanupRegistered = false; + +/** + * Remove every directory `writeCopyFile` created in this process. Synchronous, + * because it runs from a process `exit` listener, where nothing asynchronous + * gets to finish. Best-effort: a directory the user already deleted, or one + * that cannot be removed, must not turn the TUI's exit into a crash. + */ +export function removeCopyFiles(): void { + for (const dir of copyDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Nothing useful to do at exit; the directory stays owner-only. + } + copyDirs.delete(dir); + } +} + /** * Save `text` to a fresh, owner-only temp file and return its path — the * fallback for a terminal that ignores OSC 52. The directory is created with * `mkdtemp` (mode 0700) and the file with mode 0600, because the value may be - * a bearer token. + * a bearer token; for the same reason the directory is removed when the + * process exits (see {@link removeCopyFiles}). */ export function writeCopyFile( text: string, baseDir: string = os.tmpdir(), ): string { const dir = fs.mkdtempSync(path.join(baseDir, "mcp-inspector-copy-")); + // Tracked before the write, so a directory whose file write fails is still + // cleaned up rather than left empty in the temp directory. + copyDirs.add(dir); + if (!exitCleanupRegistered) { + process.on("exit", removeCopyFiles); + exitCleanupRegistered = true; + } const file = path.join(dir, "value.txt"); fs.writeFileSync(file, text, { encoding: "utf8", mode: 0o600 }); return file; From b907417344b950124975ed581ca4c1f0e212ee86 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:22:22 -0400 Subject: [PATCH 31/73] fix(auth): identify synthesized metadata by identity, not header (#2562) Response headers are server-controlled, so the compat wrapper now recognizes a synthesized document through a module-owned WeakSet; the x-inspector header is only an outward label. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 24 +++++++++++++++++++ core/auth/endpointOverrides.ts | 18 +++++++++++++- core/auth/oidcDiscoveryCompat.ts | 15 ++++++++---- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 1536ffd60a..ceb788e094 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -579,6 +579,30 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => ); }); + // Headers are server-controlled, so a real document carrying the marker + // must still be attributed to its source and warned about (Copilot). + it("does not trust a server-sent marker header in the compat wrapper", async () => { + const warn = silenceWarn(); + const spoofed = (url: string) => + url.endsWith("/.well-known/openid-configuration") + ? new Response(JSON.stringify(METADATA), { + headers: { + "content-type": "application/json", + [SYNTHESIZED_METADATA_HEADER]: "endpoint-overrides", + }, + }) + : new Response("nope", { status: 404 }); + const fetchFn = withRfc8414OidcCompat(async (input) => + spoofed(String(input)), + ); + const response = await fetchFn(AS_METADATA_URL); + expect(response.headers.get(COMPAT_SOURCE_HEADER)).toBe( + "https://as.example.com/.well-known/openid-configuration", + ); + expect(response.headers.get(SYNTHESIZED_METADATA_HEADER)).toBeNull(); + expect(String(warn.mock.calls[0][0])).toContain("returned RFC 8414"); + }); + // The issue's reproduction, through the SDK's own discovery and // authorization-URL construction: a server with no metadata anywhere, and // the compat wrapper outermost exactly as `InspectorClient` composes it. diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index b7e880d9d2..89503de796 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -284,6 +284,20 @@ function isJsonDocumentResponse(contentType: string | null): boolean { export const SYNTHESIZED_METADATA_HEADER = "x-inspector-oauth-metadata-synthesized"; +/** + * Every `Response` this module synthesized, by identity. The header above is + * only a label for the Network tab: response headers are server-controlled, + * so an authorization server could send it on a real document, and + * `withRfc8414OidcCompat` must not take its word for it (Copilot). Identity is + * something no server can forge. + */ +const synthesizedResponses = new WeakSet(); + +/** Whether this module synthesized `response` (#2562). */ +export function isSynthesizedMetadataResponse(response: Response): boolean { + return synthesizedResponses.has(response); +} + /** The OpenID Connect Discovery well-known path. */ const OIDC_WELL_KNOWN = "/.well-known/openid-configuration"; @@ -394,7 +408,7 @@ function synthesizedMetadataResponse( `the configured endpoint override(s) in place of the SDK's ` + `/authorize and /token fallbacks.`, ); - return new Response( + const synthesized = new Response( JSON.stringify(synthesizeAuthorizationServerMetadata(issuer, overrides)), { status: 200, @@ -405,6 +419,8 @@ function synthesizedMetadataResponse( }, }, ); + synthesizedResponses.add(synthesized); + return synthesized; } /** Rebuild a response around a replacement body, preserving status/headers. */ diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index b4c9cdf54c..66f3624189 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -91,7 +91,10 @@ */ import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js"; -import { SYNTHESIZED_METADATA_HEADER } from "./endpointOverrides.js"; +import { + isSynthesizedMetadataResponse, + SYNTHESIZED_METADATA_HEADER, +} from "./endpointOverrides.js"; import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, @@ -399,15 +402,17 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // A document `withOAuthEndpointOverrides` synthesized for a server that // publishes none (#2562) is not something `source` returned, so it is // neither warned about nor attributed to it — it keeps its own marker, - // and the overrides wrapper has already said what it did. - const synthesized = probe.headers.get(SYNTHESIZED_METADATA_HEADER); - if (synthesized) { + // and the overrides wrapper has already said what it did. Recognized by + // object identity, never by the marker header: headers are + // server-controlled, so a real document carrying it must still be + // warned about and attributed (Copilot). + if (isSynthesizedMetadataResponse(probe)) { return new Response(body, { status: 200, statusText: "OK", headers: { "content-type": "application/json", - [SYNTHESIZED_METADATA_HEADER]: synthesized, + [SYNTHESIZED_METADATA_HEADER]: "endpoint-overrides", }, }); } From f16ef798ee6d6e76d8101d431861aac980834f77 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:26:11 -0400 Subject: [PATCH 32/73] fix(tui): keep the '/' help row within an 80-column overlay (#2647) The longer wording truncated at 80 columns. The original text is now accurate for a kept filter too, and the global Esc row states the order. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/src/utils/keybindings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/tui/src/utils/keybindings.ts b/clients/tui/src/utils/keybindings.ts index a62fd6c680..03d9feb21e 100644 --- a/clients/tui/src/utils/keybindings.ts +++ b/clients/tui/src/utils/keybindings.ts @@ -71,7 +71,7 @@ const DETAILS_SCROLL: readonly KeyBinding[] = [ const LIST_FILTER: KeyBinding = { keys: "/", - action: "Filter the list (Enter keeps it; Esc clears it, kept or not)", + action: "Filter the list (Enter keeps it, Esc clears it)", }; const PANE_SCROLL: readonly KeyBinding[] = [ From 3f535dede88960986673efb89f84337336cda684 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:35:01 -0400 Subject: [PATCH 33/73] fix(launcher): print the version for --version / -V in every mode (#2632) `mcp-inspector --version` fell through to the default web mode and bound a port, `--cli --version` failed with "Method is required", and `--tui --version` failed with "unknown option". Each client's commander now registers `-V, --version` with the root package version (via core's readInspectorVersion), so every mode prints it and exits 0 before starting anything. The launcher's own help lists the flag, and smoke:launcher checks both spellings bare and after each mode flag. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/e2e.test.ts | 19 ++++++++++++++- clients/cli/src/cli.ts | 7 ++++++ clients/launcher/README.md | 1 + clients/launcher/src/index.ts | 5 +++- clients/tui/tui.tsx | 7 ++++++ clients/web/server/run-web.ts | 10 ++++++++ scripts/smoke-launcher.mjs | 40 +++++++++++++++++++++++++------ 7 files changed, 80 insertions(+), 9 deletions(-) diff --git a/clients/cli/__tests__/e2e.test.ts b/clients/cli/__tests__/e2e.test.ts index e8e485f607..341ba4a9ff 100644 --- a/clients/cli/__tests__/e2e.test.ts +++ b/clients/cli/__tests__/e2e.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { spawn } from "node:child_process"; import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; @@ -226,4 +226,21 @@ describe("CLI binary (out-of-process E2E)", () => { }, E2E_SPAWN_MS, ); + + // #2632: `--version` used to fall through to the missing-method error. Out of + // process because commander exits 0 itself, which would end the test worker. + const { version } = JSON.parse( + readFileSync(resolve(here, "../../../package.json"), "utf8"), + ) as { version: string }; + + it.each(["--version", "-V"])( + "prints the root version and exits 0 for %s", + async (flag) => { + const result = await spawnCli([flag]); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(version); + }, + E2E_SPAWN_MS, + ); }); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 38b0dcdf6d..7c3f41f45a 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -851,6 +851,13 @@ async function parseArgs(argv?: string[]): Promise { program .name("inspector-cli") + // `-V, --version` prints the root package version and exits 0 through the + // exitOverride's non-throwing arm above, like `--help` (#2632). + .version( + readInspectorVersion(import.meta.url), + "-V, --version", + "Print the Inspector version", + ) .allowUnknownOption() .argument( "[target...]", diff --git a/clients/launcher/README.md b/clients/launcher/README.md index af91f9a6ea..280f5e75f7 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -8,6 +8,7 @@ The root `package.json` also publishes it as an `inspector` bin. That alias is w - Parse mode from a leading prefix of `--web` (default), `--cli`, or `--tui` immediately after the script name. - Forward all following arguments unchanged (including tokens that look like mode flags). +- `--version` / `-V` is forwarded like any other flag; each client prints the root package version and exits `0` without starting anything, so `mcp-inspector --version`, `--cli --version` and `--tui --version` all print the same version (#2632). `smoke:launcher` checks every mode. - Dynamically import that app’s runner from `clients/{web,cli,tui}/build/index.js` (relative to the launcher build output) and call it **in-process** (no `spawn()`). All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret server or client configuration, from files or from env vars. Its one exception is the debug switch described below. diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index e42f4e53c8..39582551e1 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -34,7 +34,10 @@ program .description("MCP Inspector – run web UI, CLI, or TUI") .option("--web", "Run web UI (default)") .option("--cli", "Run CLI") - .option("--tui", "Run TUI"); + .option("--tui", "Run TUI") + // Listed for the launcher's own help only: the flag is forwarded to the + // selected client (web by default), whose commander prints the version. + .option("-V, --version", "Print the Inspector version"); let parsedArgv; try { diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 4d3d604fc0..acf6a87888 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -10,6 +10,7 @@ import { } from "@inspector/core/mcp/node/index.js"; import type { ServerProtocolEra } from "@inspector/core/mcp/types.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; import { parseRunnerOAuthCallbackUrl, DEFAULT_RUNNER_OAUTH_CALLBACK_URL, @@ -23,6 +24,12 @@ export async function runTui(args?: string[]): Promise { program .name("mcp-inspector-tui") .description("Terminal UI for MCP Inspector") + // Prints the root package version and exits before Ink renders (#2632). + .version( + readInspectorVersion(import.meta.url), + "-V, --version", + "Print the Inspector version", + ) .option( "--catalog ", "Writable catalog file (created if missing; default: ~/.mcp-inspector/mcp.json, or MCP_CATALOG_PATH)", diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index a76791faed..7df47c9bde 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -27,6 +27,7 @@ import { import { startViteDevServer } from "./start-vite-dev-server.js"; import { startHonoServer } from "./server.js"; import { ensureWebBuild } from "./ensure-web-build.js"; +import { readInspectorVersionSafe } from "../../../core/node/version.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -71,6 +72,15 @@ export async function runWeb(argv: string[]): Promise { program .name("mcp-inspector-web") .description("Web UI for MCP Inspector") + // Prints the root package version and exits before any server binds — the + // web mode is the launcher's default, so a bare `mcp-inspector --version` + // lands here (#2632). The safe reader matches web-server-config.ts: an + // unresolvable manifest must not take startup down. + .version( + readInspectorVersionSafe(import.meta.url) ?? "unknown", + "-V, --version", + "Print the Inspector version", + ) .allowExcessArguments() .allowUnknownOption() .option( diff --git a/scripts/smoke-launcher.mjs b/scripts/smoke-launcher.mjs index bfb5434d4b..363535a1a5 100644 --- a/scripts/smoke-launcher.mjs +++ b/scripts/smoke-launcher.mjs @@ -9,18 +9,20 @@ * - `--help` → the launcher's own usage (mode flags). * - `--cli --help` → dispatched to the CLI binary's help. * - `--tui --help` → dispatched to the TUI binary's help. + * - `--version` / `-V`, bare and after each mode flag → the root package + * version alone, without starting a server (#2632). * * Each must exit 0 and print the mode's distinctive usage banner, which also * confirms the launcher resolved and loaded that client's build. Exits non-zero * on the first failing check. * - * Expects `clients/{launcher,cli,tui}/build` to exist (the validate / CI - * ordering guarantees this) — `--cli`/`--tui` dispatch dynamically imports the - * respective client build. + * Expects `clients/{launcher,web,cli,tui}/build` to exist (the validate / CI + * ordering guarantees this) — every dispatch, the default web mode included, + * dynamically imports the respective client build. */ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, ".."); @@ -31,7 +33,7 @@ function fail(message) { process.exit(1); } -for (const client of ["launcher", "cli", "tui"]) { +for (const client of ["launcher", "web", "cli", "tui"]) { const build = join(repoRoot, "clients", client, "build", "index.js"); if (!existsSync(build)) { fail(`${client} build not found at ${build} — run \`npm run build\` first`); @@ -58,10 +60,28 @@ const checks = [ }, ]; -for (const { args, marker, label } of checks) { +// `--version` must print exactly the root version and exit 0 in every mode — +// before #2632 the bare form fell through to web and bound a port. The output +// is matched whole, so a server banner printed alongside it also fails. +const { version } = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf8"), +); +for (const prefix of [[], ["--web"], ["--cli"], ["--tui"]]) { + for (const flag of ["--version", "-V"]) { + checks.push({ + args: [...prefix, flag], + exact: version, + label: `${[...prefix, flag].join(" ")} prints the version`, + }); + } +} + +for (const { args, marker, exact } of checks) { const r = spawnSync(process.execPath, [launcher, ...args], { cwd: repoRoot, encoding: "utf8", + // A regressed `--version` starts a server that never exits on its own. + timeout: 30_000, }); const output = `${r.stdout ?? ""}${r.stderr ?? ""}`; if (r.status !== 0) { @@ -69,7 +89,13 @@ for (const { args, marker, label } of checks) { `\`${args.join(" ")}\` exited with code ${r.status}\n${output.slice(0, 800)}`, ); } - if (!output.includes(marker)) { + if (exact !== undefined) { + if (output.trim() !== exact) { + fail( + `\`${args.join(" ")}\` printed something other than "${exact}"\n${output.slice(0, 800)}`, + ); + } + } else if (!output.includes(marker)) { fail( `\`${args.join(" ")}\` did not print "${marker}"\n${output.slice(0, 800)}`, ); From 9bb8d752f4c174c4f29740679234b9973bddc1d2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:45:32 -0400 Subject: [PATCH 34/73] fix(auth): exclude well-known-prefixed candidates from final-candidate match (#2562) An authorization server whose own path ends in /.well-known/openid-configuration made every discovery candidate end with that suffix, so synthesis fired on the first one with a wrong issuer. The earlier candidates always start with a well-known segment, so exclude those. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 11 ++++++ core/auth/endpointOverrides.ts | 36 ++++++++++++++----- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index ceb788e094..b17d180b06 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -417,6 +417,9 @@ describe("authorizationServerForFinalCandidate", () => { "https://gateway.example.com/", "https://gateway.example.com/tenant", "https://gateway.example.com/tenant/", + // An issuer whose own path ends in the OIDC suffix makes *every* candidate + // end that way; only the last may be claimed (Copilot). + "https://gateway.example.com/tenant/.well-known/openid-configuration", ])("identifies only the SDK's last candidate for %s", (asUrl) => { const candidates = buildDiscoveryUrls(asUrl).map((c) => c.url.href); const matched = candidates.map(authorizationServerForFinalCandidate); @@ -426,6 +429,14 @@ describe("authorizationServerForFinalCandidate", () => { expect(matched.at(-1)).toBe(asUrl.replace(/\/$/, "")); }); + it("declines an issuer whose path begins with a well-known segment", () => { + expect( + authorizationServerForFinalCandidate( + "https://gateway.example.com/.well-known/openid-configuration/x/.well-known/openid-configuration", + ), + ).toBeUndefined(); + }); + it("rejects a URL that will not parse", () => { expect(authorizationServerForFinalCandidate("not a url")).toBeUndefined(); }); diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 89503de796..edcbbe5e56 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -301,6 +301,9 @@ export function isSynthesizedMetadataResponse(response: Response): boolean { /** The OpenID Connect Discovery well-known path. */ const OIDC_WELL_KNOWN = "/.well-known/openid-configuration"; +/** The RFC 8414 well-known path. */ +const RFC8414_WELL_KNOWN = "/.well-known/oauth-authorization-server"; + /** * Whether a status makes the SDK move on to the next discovery candidate — * a 4xx or a 502, mirroring `discoverAuthorizationServerMetadata`. Anything @@ -318,12 +321,20 @@ function continuesDiscovery(status: number): boolean { * The SDK's `buildDiscoveryUrls` always ends its list with the path-appended * OIDC form, `${path}/.well-known/openid-configuration` (just * `/.well-known/openid-configuration` for an authorization server at the - * origin), and no earlier candidate ends that way: the RFC 8414 form and the - * path-inserted OIDC form both carry the authorization server's path *after* - * the well-known segment. So "the pathname ends with the OIDC well-known path" - * identifies the last candidate, and stripping that suffix recovers the - * authorization server it was built from. Synthesizing anywhere earlier would - * pre-empt a document a later candidate actually serves. + * origin). The earlier candidates — the RFC 8414 form and the path-inserted + * OIDC form — both carry the authorization server's path *after* a well-known + * segment, so they start with `/.well-known/oauth-authorization-server/` or + * `/.well-known/openid-configuration/`. Ending with the suffix alone is not + * enough: an authorization server whose own path ends in + * `/.well-known/openid-configuration` makes every candidate end that way + * (Copilot). So the final candidate is the one that ends with the suffix and + * does **not** start with a well-known prefix, and stripping the suffix + * recovers the authorization server it was built from. Synthesizing anywhere + * earlier would pre-empt a document a later candidate actually serves. + * + * The one URL this declines that is genuinely final is an authorization server + * whose own path begins with a well-known segment. That only means no + * synthesis — discovery falls back exactly as it did before #2562. */ export function authorizationServerForFinalCandidate( url: string, @@ -334,9 +345,16 @@ export function authorizationServerForFinalCandidate( } catch { return undefined; } - if (!parsed.pathname.endsWith(OIDC_WELL_KNOWN)) return undefined; - const path = parsed.pathname.slice(0, -OIDC_WELL_KNOWN.length); - return `${parsed.origin}${path}`; + const { pathname } = parsed; + if (pathname === OIDC_WELL_KNOWN) return parsed.origin; + if (!pathname.endsWith(OIDC_WELL_KNOWN)) return undefined; + if ( + pathname.startsWith(`${RFC8414_WELL_KNOWN}/`) || + pathname.startsWith(`${OIDC_WELL_KNOWN}/`) + ) { + return undefined; + } + return `${parsed.origin}${pathname.slice(0, -OIDC_WELL_KNOWN.length)}`; } /** From f3fdd308a618b0949fea0dd7059fee67b7d76535 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:52:12 -0400 Subject: [PATCH 35/73] docs(release): drive mcpdo in the milestone smoke (#2628) Step 2a now names mcpdo and the Docker image alongside web, the one-shot CLI and the TUI, and requires a core/cli change to be driven through both CLIs since they share its handlers, exit codes, OAuth flow and styling. Step 2b notes that mcpdo ledger rows run connect -> command -> disconnect and that a stale mcpdod must be killed before the first row. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index d076c308e1..844f6baae1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -161,9 +161,13 @@ the `pack:verify OK` line) for the ledger below. **Every contribution closed in the milestone gets driven, not read.** The bar is observed behavior from the running app — a rendered panel, a status attribute, a -server's own stderr — against a real test server, through whichever clients the -change touches (web, CLI, TUI). "Its tests pass" is not evidence for this step; -the gate already said that. For a change with no observable surface, the +server's own stderr — against a real test server, through whichever surfaces the +change touches: the web client, the one-shot CLI, the TUI, `mcpdo` (the +connection CLI, shipped in the same package since 2.10.0) and the Docker image. +**A change to `core/cli/` is driven through both CLIs** — the one-shot CLI and +`mcpdo` share its method handlers, exit codes, OAuth flow and output styling +(#2461), so passing through one says nothing about the other. "Its tests pass" +is not evidence for this step; the gate already said that. For a change with no observable surface, the evidence is the thing that holds it — a probe that makes the guard fire, a counted before/after, a resolved binary path. @@ -187,6 +191,14 @@ and link it from PR 2. Shape it like the - **One section per theme**, each a table of *Issue · What was driven · Observed · Status*. One row per closed issue, issue-linked, with the actual output in the Observed cell. + - **An `mcpdo` row runs against a real connection** — `mcpdo connect …`, the + command under test, then `mcpdo disconnect …` — and its *What was driven* + cell shows all three. ⚠️ The connection daemon (`mcpdod`) outlives a + rebuild and keeps serving the code it started with, so a daemon left over + from before the production build smokes the wrong bundle and the row reads + as a pass. Run `pkill mcpdod` before the first `mcpdo` row (or rebuild with + `npm run build:dev` in `clients/mcpdo`, which stops it); see + `clients/mcpdo/README.md`. - **Notes / findings** — anything that is a caveat rather than a pass, called out rather than folded into a row. From bf5224613cf5cae2634115256947d8a7a8a650bd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:55:56 -0400 Subject: [PATCH 36/73] fix(cli): take the task path for a taskSupport: required tool (#2649) A legacy tool declaring execution.taskSupport: "required" refuses a plain callTool(), and the one-shot CLI has no --task flag, so it could never call one. runMethod now uses callToolStream for such a tool whether or not task was requested; mcpdo shares runMethod and no longer needs --task for it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/tools.test.ts | 43 ++++++++++++++++++++++++++++- core/cli/handlers/method-types.ts | 5 +++- core/cli/handlers/run-method.ts | 7 ++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/clients/cli/__tests__/tools.test.ts b/clients/cli/__tests__/tools.test.ts index 8ca50f474e..ee9587bc28 100644 --- a/clients/cli/__tests__/tools.test.ts +++ b/clients/cli/__tests__/tools.test.ts @@ -5,7 +5,12 @@ import { expectCliFailure, expectValidJson, } from "./helpers/assertions.js"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + getTestMcpServerCommand, + createTestServerHttp, + createTestServerInfo, + createSimpleTaskTool, +} from "@modelcontextprotocol/inspector-test-server"; describe("Tool Tests", () => { describe("Tool Discovery", () => { @@ -530,4 +535,40 @@ describe("Tool Tests", () => { expect(resultData.result).toBe(30); }); }); + + describe("Task-required tools", () => { + // A legacy tool declaring `taskSupport: "required"` refuses a plain + // callTool(); the CLI must take the task path for it without being told, + // and print the task's result (#2649). + it("calls a taskSupport: required tool and prints its result", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo("tasks-legacy", "1.0.0"), + tools: [createSimpleTaskTool("simple_task", 50)], + tasks: { list: true, cancel: true }, + logging: true, + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--transport", + "http", + "--method", + "tools/call", + "--tool-name", + "simple_task", + ]); + + expectCliSuccess(result); + const json = expectValidJson(result); + expect(json.isError).not.toBe(true); + expect(Array.isArray(json.content)).toBe(true); + expect(json.content.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }); + }); }); diff --git a/core/cli/handlers/method-types.ts b/core/cli/handlers/method-types.ts index 20e11773cc..cd45e89987 100644 --- a/core/cli/handlers/method-types.ts +++ b/core/cli/handlers/method-types.ts @@ -55,7 +55,10 @@ export type MethodArgs = { outputFormat?: OutputFileFormat; /** Task id for tasks/get, tasks/cancel, tasks/result, tasks/update. */ taskId?: string; - /** When true, tools/call uses callToolStream (task-augmented). */ + /** + * When true, tools/call uses callToolStream (task-augmented). A tool whose + * `execution.taskSupport` is `"required"` takes that path regardless. + */ task?: boolean; /** * `--verify`: run the SEP-2640 conformance and digest checks over the skills diff --git a/core/cli/handlers/run-method.ts b/core/cli/handlers/run-method.ts index e8ae570db9..2b02b1e6ef 100644 --- a/core/cli/handlers/run-method.ts +++ b/core/cli/handlers/run-method.ts @@ -184,7 +184,12 @@ export async function runMethod( if (args.appInfo) { result = { ...appInfo }; } else { - const invocation = args.task + // A tool declaring `taskSupport: "required"` (legacy era) can only be + // called task-augmented — callTool() refuses it outright — so take the + // task path for it whether or not `--task` was passed (#2649). + const useTask = + args.task === true || tool.execution?.taskSupport === "required"; + const invocation = useTask ? await inspectorClient.callToolStream( tool, args.toolArg || {}, From 1eb3354a96491b18f1827c2b16690f5a6769fb90 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:56:28 -0400 Subject: [PATCH 37/73] fix(cli): exit quietly when --completion's reader closes early (#2646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp-inspector --cli --completion ` wrote its script through awaitableLog with no stdout 'error' listener, so a reader that closed the pipe first (`| head`, bash 3.2's `source <(…)`) crashed the CLI with an unhandled `write EPIPE`. The script write now treats EPIPE as a normal end and rejects any other stdout error into the CLI's error path, reusing the consume-outcome stream's isBrokenPipe. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/completion.test.ts | 65 ++++++++++++++++++++- clients/cli/__tests__/e2e.test.ts | 42 +++++++++++++ clients/cli/src/completion.ts | 34 ++++++++++- clients/cli/src/handlers/consume-outcome.ts | 2 +- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/clients/cli/__tests__/completion.test.ts b/clients/cli/__tests__/completion.test.ts index 4c7043428d..304560c33a 100644 --- a/clients/cli/__tests__/completion.test.ts +++ b/clients/cli/__tests__/completion.test.ts @@ -4,7 +4,7 @@ * shell. The shell-level tests spawn bash / zsh / fish when installed and are * skipped otherwise; the structural tests below always run. */ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect, afterAll, afterEach, vi } from "vitest"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -409,3 +409,66 @@ describe.skipIf(!hasShell("fish"))("fish script", () => { ).toEqual(["--method=tools/call", "--method=tools/list"]); }); }); + +/** + * #2646: the script write treats EPIPE (a reader that closed early) as a + * normal end. Node calls the write callback with the error and then emits + * `'error'`, so the fake does both, in that order. + */ +describe("emitCompletionIfRequested stdout errors", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function failWritesWith(err: Error): void { + vi.spyOn(process.stdout, "write").mockImplementation((( + _chunk: unknown, + cb?: (err?: Error | null) => void, + ) => { + cb?.(err); + process.nextTick(() => process.stdout.emit("error", err)); + return false; + }) as typeof process.stdout.write); + } + + function completionProgram(): Command { + const program = new Command(); + registerCompletionOption(program); + program.parse(["--completion", "bash"], { from: "user" }); + return program; + } + + async function flushErrorEmit(): Promise { + await new Promise((resolve) => process.nextTick(resolve)); + } + + it("resolves on EPIPE and leaves no listener behind", async () => { + const before = process.stdout.listenerCount("error"); + failWritesWith(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + expect(await emitCompletionIfRequested(completionProgram())).toBe(true); + await flushErrorEmit(); + expect(process.stdout.listenerCount("error")).toBe(before); + }); + + it("rejects any other stdout error", async () => { + const err = Object.assign(new Error("write EIO"), { code: "EIO" }); + failWritesWith(err); + await expect(emitCompletionIfRequested(completionProgram())).rejects.toBe( + err, + ); + await flushErrorEmit(); + }); + + it("detaches its listener after a successful write", async () => { + const before = process.stdout.listenerCount("error"); + vi.spyOn(process.stdout, "write").mockImplementation((( + _chunk: unknown, + cb?: (err?: Error | null) => void, + ) => { + cb?.(); + return true; + }) as typeof process.stdout.write); + expect(await emitCompletionIfRequested(completionProgram())).toBe(true); + expect(process.stdout.listenerCount("error")).toBe(before); + }); +}); diff --git a/clients/cli/__tests__/e2e.test.ts b/clients/cli/__tests__/e2e.test.ts index 341ba4a9ff..d2309bc750 100644 --- a/clients/cli/__tests__/e2e.test.ts +++ b/clients/cli/__tests__/e2e.test.ts @@ -77,6 +77,35 @@ function spawnCli( }); } +/** + * Spawn the binary with its stdout read end closed before the child can write, + * so its first stdout write fails with EPIPE — the `| head` / bash 3.2 + * `source <(…)` early close (#2646), without a shell or a timing race: node + * boots for far longer than the close takes. + */ +function spawnCliWithClosedStdout(args: string[]): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn("node", [BIN, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error("E2E CLI timed out")); + }, CHILD_TIMEOUT_MS); + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (exitCode) => { + clearTimeout(timer); + resolvePromise({ exitCode, stdout: "", stderr }); + }); + }); +} + describe("CLI binary (out-of-process E2E)", () => { const { command, args } = getTestMcpServerCommand(); @@ -243,4 +272,17 @@ describe("CLI binary (out-of-process E2E)", () => { }, E2E_SPAWN_MS, ); + + // #2646: a reader that closes before the completion script is written used + // to crash the CLI with an unhandled `'error'` event (`write EPIPE`). + it( + "exits quietly when --completion's reader closes early", + async () => { + const result = await spawnCliWithClosedStdout(["--completion", "bash"]); + + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }, + E2E_SPAWN_MS, + ); }); diff --git a/clients/cli/src/completion.ts b/clients/cli/src/completion.ts index 90cfdf4b7f..0f352d306a 100644 --- a/clients/cli/src/completion.ts +++ b/clients/cli/src/completion.ts @@ -24,9 +24,9 @@ import type { Command, Option } from "commander"; import { LoggingLevelSchema } from "@modelcontextprotocol/core"; import { ONE_SHOT_METHODS } from "@inspector/core/cli/handlers/method-types.js"; -import { awaitableLog } from "@inspector/core/cli/utils/awaitable-log.js"; import { OUTPUT_FILE_FORMATS } from "@inspector/core/cli/handlers/output-file.js"; import { CATALOG_WRITE_METHODS } from "./handlers/servers-write.js"; +import { isBrokenPipe } from "./handlers/consume-outcome.js"; export const COMPLETION_SHELLS = ["bash", "zsh", "fish"] as const; export type CompletionShell = (typeof COMPLETION_SHELLS)[number]; @@ -351,6 +351,34 @@ export function registerCompletionOption(program: Command): void { ); } +/** + * Write the script to stdout, treating a reader that closes early as a normal + * end rather than a crash (#2646). `| head` closes the pipe before the whole + * script is written, and so does bash 3.2's `source <(…)`, whose process + * substitution does not keep it open; either way the write fails with EPIPE, + * which without an `'error'` listener is an unhandled event and a stack trace. + * Any other stdout error still rejects into the CLI's error path. + * + * The listener stays attached after a failed write: Node calls the write + * callback first and emits `'error'` afterwards, so detaching in the callback + * would leave that later emit unhandled. It is a `once`, so the emit removes + * it, and settling twice is a no-op. + */ +function writeCompletion(script: string): Promise { + return new Promise((resolve, reject) => { + const settle = (err: unknown) => { + if (isBrokenPipe(err)) resolve(); + else reject(err); + }; + process.stdout.once("error", settle); + process.stdout.write(script, (err) => { + if (err) return settle(err); + process.stdout.off("error", settle); + resolve(); + }); + }); +} + /** * After `program.parse`: when `--completion` was given, write the script for * that shell and return true so the caller can short-circuit. @@ -360,6 +388,8 @@ export async function emitCompletionIfRequested( ): Promise { const shell = program.opts<{ completion?: CompletionShell }>().completion; if (!shell) return false; - await awaitableLog(renderCompletion(shell, collectCompletionFlags(program))); + await writeCompletion( + renderCompletion(shell, collectCompletionFlags(program)), + ); return true; } diff --git a/clients/cli/src/handlers/consume-outcome.ts b/clients/cli/src/handlers/consume-outcome.ts index 45583ae002..c49c1dba93 100644 --- a/clients/cli/src/handlers/consume-outcome.ts +++ b/clients/cli/src/handlers/consume-outcome.ts @@ -16,7 +16,7 @@ import type { * True for the error a write to a closed pipe raises — the reader went away * (`| head`, `| grep -m1`, quitting `less`), which ends the stream normally. */ -function isBrokenPipe(err: unknown): boolean { +export function isBrokenPipe(err: unknown): boolean { return ( err instanceof Error && (err as NodeJS.ErrnoException).code === "EPIPE" ); From 0a6724efb24708d5750336f1893af807a69b8462 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:56:52 -0400 Subject: [PATCH 38/73] fix(auth): synthesize metadata only for JSON discovery requests (#2562) URL shape plus GET also matches an SSE/streamable GET to an MCP endpoint whose URL ends in the OIDC well-known path, whose OAuth 401 would have been replaced. SDK discovery always sends Accept: application/json and those transports ask for text/event-stream, so require the former. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 40 ++++++++++++++----- core/auth/endpointOverrides.ts | 26 ++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index b17d180b06..95f2d346e2 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -478,6 +478,9 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => tokenUrl: STAGING_TOKEN, }; + /** What the SDK's discovery sends; synthesis requires a JSON `Accept`. */ + const DISCOVERY_INIT = { headers: { Accept: "application/json" } }; + function silenceWarn() { return vi.spyOn(console, "warn").mockImplementation(() => {}); } @@ -488,7 +491,7 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => passThrough(new Response("nope", { status: 404 })), () => OVERRIDES, ); - const response = await wrapped(FINAL_CANDIDATE); + const response = await wrapped(FINAL_CANDIDATE, DISCOVERY_INIT); expect(response.status).toBe(200); expect(response.headers.get(SYNTHESIZED_METADATA_HEADER)).toBe( "endpoint-overrides", @@ -507,10 +510,9 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => passThrough(new Response("bad gateway", { status: 502 })), () => OVERRIDES, ); - await expect(wrapped(FINAL_CANDIDATE)).resolves.toHaveProperty( - "status", - 200, - ); + await expect( + wrapped(FINAL_CANDIDATE, DISCOVERY_INIT), + ).resolves.toHaveProperty("status", 200); }); it("leaves a 500 alone — the SDK surfaces it as an outage", async () => { @@ -552,10 +554,30 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => passThrough(new Response("nope", { status: 404 })), () => OVERRIDES, ); - await expect(wrapped(new Request(FINAL_CANDIDATE))).resolves.toHaveProperty( - "status", - 200, + await expect( + wrapped(new Request(FINAL_CANDIDATE, DISCOVERY_INIT)), + ).resolves.toHaveProperty("status", 200); + }); + + // A legacy SSE / streamable-HTTP GET to an MCP endpoint whose URL happens + // to end in the OIDC well-known path must keep its OAuth 401 (Copilot). + it("does not synthesize for an event-stream GET", async () => { + const original = new Response("unauthorized", { status: 401 }); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => OVERRIDES, ); + await expect( + wrapped(FINAL_CANDIDATE, { headers: { Accept: "text/event-stream" } }), + ).resolves.toBe(original); + await expect( + wrapped( + new Request(FINAL_CANDIDATE, { + headers: { Accept: "text/event-stream" }, + }), + ), + ).resolves.toBe(original); + await expect(wrapped(FINAL_CANDIDATE)).resolves.toBe(original); }); it("does nothing when no override is configured", async () => { @@ -577,7 +599,7 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => () => OVERRIDES, ), ); - const response = await fetchFn(AS_METADATA_URL); + const response = await fetchFn(AS_METADATA_URL, DISCOVERY_INIT); expect(response.status).toBe(200); expect(response.headers.get(SYNTHESIZED_METADATA_HEADER)).toBe( "endpoint-overrides", diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index edcbbe5e56..23e6519d89 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -399,6 +399,31 @@ function requestMethodOf( return "GET"; } +/** + * Whether a request asked for a JSON document, as the SDK's metadata discovery + * always does (`Accept: application/json`). + * + * URL shape and method alone do not prove a request is discovery: a legacy SSE + * or streamable-HTTP `GET` to an MCP endpoint whose URL happens to end in the + * OIDC well-known path would match both, and replacing its OAuth 401 with a + * synthesized document would stop the transport from ever starting OAuth + * (Copilot). Those transports ask for `text/event-stream`, so requiring a JSON + * `Accept` separates the two. `withRfc8414OidcCompat` copies the SDK's request + * headers onto its probes, so its probes pass too. + */ +function acceptsJson( + input: RequestInfo | URL, + init: RequestInit | undefined, +): boolean { + let accept: string | null = null; + if (init?.headers) { + accept = new Headers(init.headers).get("accept"); + } else if (typeof input !== "string" && !(input instanceof URL)) { + accept = input.headers.get("accept"); + } + return accept?.toLowerCase().includes("application/json") ?? false; +} + /** * The synthesized-document response for a failed final discovery candidate, * or `undefined` when this failure is not one discovery would walk past. @@ -413,6 +438,7 @@ function synthesizedMetadataResponse( // Discovery is a `GET`; an endpoint that merely lives under a well-known // path answering some other method is not ours to replace. if (requestMethodOf(input, init) !== "GET") return undefined; + if (!acceptsJson(input, init)) return undefined; const issuer = authorizationServerForFinalCandidate(requestUrlOf(input)); if (!issuer) return undefined; From 70e94eaef5e7257a711437d1b33c488f1a8a2105 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:56:58 -0400 Subject: [PATCH 39/73] fix(mcpdo): restore a fresh OAuth-flow lock instead of deleting it on a lost steal (#2643) When two stealers interleave on a stale per-server lock, the second one renames the first's fresh lock, sees it is fresh and backs off - but deleted it on the way out, letting a third connect win create() and spawn a competing sign-in helper. Link the claim back to the lock path (which refuses if a newer lock exists) before removing the claim, so only a lock confirmed stale is ever discarded. Tests inject the interleaving through a pass-through renameSync seam. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/mcpdo/__tests__/auth-helper.test.ts | 69 +++++++++++++++++++++ clients/mcpdo/src/connection/auth-helper.ts | 27 +++++--- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/clients/mcpdo/__tests__/auth-helper.test.ts b/clients/mcpdo/__tests__/auth-helper.test.ts index e4368c5d0b..1dc78f0ba6 100644 --- a/clients/mcpdo/__tests__/auth-helper.test.ts +++ b/clients/mcpdo/__tests__/auth-helper.test.ts @@ -7,6 +7,14 @@ import type { CallbackNavigation } from "@inspector/core/auth/index.js"; const authorizeInFrontend = vi.fn(); +// Wrap renameSync in a pass-through vi.fn so the stale-lock steal tests can +// inject a concurrent stealer in the stat→rename window (ESM namespaces +// cannot be spied on directly). +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, renameSync: vi.fn(actual.renameSync) }; +}); + vi.mock("../src/connection/authorize.js", () => ({ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), })); @@ -319,6 +327,67 @@ describe("auth-helper", () => { // place — either way this process never spawned a helper. }); + describe("a stealer that claims a lock another stealer just refreshed", () => { + const realRename = vi.mocked(fs.renameSync).getMockImplementation()!; + + afterEach(() => { + vi.mocked(fs.renameSync).mockImplementation(realRename); + }); + + function staleLock(): string { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockPath, old, old); + return lockPath; + } + + function reserveAndExpectBackOff() { + return expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + // Would fail loudly if a spawn were attempted. + { + helperArgv1: path.join(dir, "does-not-exist.mjs"), + waitMs: 60, + pollMs: 10, + }, + ), + ).rejects.toMatchObject({ envelope: { code: "auth_required" } }); + } + + it("restores the fresh lock instead of deleting it", async () => { + const lockPath = staleLock(); + // Stealer A wins first: it replaces the stale lock with a fresh one + // after this process saw it stale but before this process renames. + vi.mocked(fs.renameSync).mockImplementationOnce((from, to) => { + fs.rmSync(lockPath); + fs.writeFileSync(lockPath, "4242\n", { flag: "wx", mode: 0o600 }); + realRename(from, to); + }); + await reserveAndExpectBackOff(); + // A's lock is back in place, so a third connect cannot win create(). + expect(fs.readFileSync(lockPath, "utf8")).toBe("4242\n"); + expect(fs.existsSync(`${lockPath}.claim-${process.pid}`)).toBe(false); + }); + + it("backs off without restoring when a newer lock already took the path", async () => { + const lockPath = staleLock(); + vi.mocked(fs.renameSync).mockImplementationOnce((from, to) => { + fs.rmSync(lockPath); + fs.writeFileSync(lockPath, "4242\n", { flag: "wx", mode: 0o600 }); + realRename(from, to); + // A third reserver created a lock in the now-empty path. + fs.writeFileSync(lockPath, "5353\n", { flag: "wx", mode: 0o600 }); + }); + await reserveAndExpectBackOff(); + // The link refused to clobber the newer lock, and the claim is gone. + expect(fs.readFileSync(lockPath, "utf8")).toBe("5353\n"); + expect(fs.existsSync(`${lockPath}.claim-${process.pid}`)).toBe(false); + }); + }); + it("maps a helper spawn failure to auth_required", async () => { const originalExecPath = process.execPath; // A nonexistent interpreter makes spawn emit `error` instead of `exit`. diff --git a/clients/mcpdo/src/connection/auth-helper.ts b/clients/mcpdo/src/connection/auth-helper.ts index 073779a867..cd67546d4e 100644 --- a/clients/mcpdo/src/connection/auth-helper.ts +++ b/clients/mcpdo/src/connection/auth-helper.ts @@ -241,9 +241,9 @@ export async function runAuthHelper(): Promise { * A leftover lock from a crashed reserver is stolen once it is older than * the URL wait window. The steal claims the specific stale file by an * atomic rename to a per-pid path — concurrent stealers cannot both win, - * and a winner that renamed a lock which turned out to be fresh backs off - * (POSIX has no compare-and-delete; the rename makes the claim itself - * exclusive, which is what prevents a double spawn). + * and a winner that renamed a lock which turned out to be fresh restores it + * and backs off (POSIX has no compare-and-delete; the rename makes the claim + * itself exclusive, which is what prevents a double spawn). * * @returns true if this process holds the reservation. */ @@ -261,13 +261,22 @@ function tryReserveAuthFlow(lockPath: string): boolean { // Claim the stale lock atomically: only one renamer succeeds. const claimPath = `${lockPath}.claim-${process.pid}`; fs.renameSync(lockPath, claimPath); - const claimedFresh = !isStale(fs.statSync(claimPath).mtimeMs); + if (!isStale(fs.statSync(claimPath).mtimeMs)) { + // The claimed file was recreated fresh between stat and rename: an + // active reserver (a stealer that won first) holds the flow. Put its + // lock back before backing off — deleting it would let a third + // connect win create() and spawn a competing helper (#2643). The + // link refuses (EEXIST) if a newer lock already took the path, and + // the outer catch then backs off all the same; either way only a + // lock confirmed stale is ever discarded. + try { + fs.linkSync(claimPath, lockPath); + } finally { + fs.rmSync(claimPath, { force: true }); + } + return false; + } fs.rmSync(claimPath, { force: true }); - // The claimed file was recreated fresh between stat and rename: an - // active reserver holds the flow — back off and wait for its marker. - // (Un-injectable microsecond race; the guard is what matters.) - /* v8 ignore next */ - if (claimedFresh) return false; create(); return true; } catch { From 786148b9dfc1e81272a938f0c99175046433ca52 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 14:59:23 -0400 Subject: [PATCH 40/73] fix(cli): keep the stdio target when an option precedes it (#2631) The CLI split the ad-hoc target off as the leading run of non-dash tokens before Commander ran, so any flag ahead of the target (-q, --strict, --format json, ...) left the target empty and the run fell back to the catalog. Unknown flags after the target (--crashable) were silently dropped, so a server's own flags could not be passed without a config file. The target is now whatever Commander does not consume as an Inspector option: its positionals plus unknown flags, in order. Unknown flags ahead of the target's first token are still ignored. The documented ` -- ` form is kept; a line that starts with an option reads `--` as conventional end-of-options instead. `--cli` is registered as a hidden, ignored option so a stray one is not forwarded. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/arg-order.test.ts | 121 ++++++++++++++++++++++++ clients/cli/src/cli.ts | 64 ++++++++++--- docs/cli-smoke-testing.md | 18 ++-- docs/mcp-server-configuration.md | 25 +++-- docs/v1-to-v2-migration.md | 21 ++-- 5 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 clients/cli/__tests__/arg-order.test.ts diff --git a/clients/cli/__tests__/arg-order.test.ts b/clients/cli/__tests__/arg-order.test.ts new file mode 100644 index 0000000000..313c00e1ad --- /dev/null +++ b/clients/cli/__tests__/arg-order.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { + CRASHABLE_FLAG, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; +import { runCli } from "./helpers/cli-runner.js"; +import { expectCliSuccess, expectJsonStructure } from "./helpers/assertions.js"; + +/** + * Where an ad-hoc stdio target may sit relative to the Inspector's own + * options, and how arguments reach the server (#2631). The target is whatever + * Commander does not consume as an Inspector option, so option order around it + * does not matter and a flag the Inspector does not define is forwarded. + * + * The crashable test server is the probe for "did the argument reach the + * server": started with `--crashable` it advertises a `crash_server` tool the + * default configuration does not. + */ +describe("CLI argument order (#2631)", () => { + const { command, args } = getTestMcpServerCommand(); + const target = [command, ...args]; + + async function toolNames(argv: string[]): Promise { + const result = await runCli(argv); + expectCliSuccess(result); + const json = expectJsonStructure(result, ["tools"]); + return (json.tools as { name: string }[]).map((tool) => tool.name); + } + + it.each([ + ["-q"], + ["--quiet"], + ["--strict"], + ["--advertise-apps"], + ["--stored-auth-only"], + ["--format", "json"], + ])("keeps the target when %s precedes it", async (...flag) => { + const result = await runCli([...flag, ...target, "--method", "tools/list"]); + expectCliSuccess(result); + expect(result.stdout).toContain('"echo"'); + }); + + it("keeps the target when --method precedes it", async () => { + expect(await toolNames(["--method", "tools/list", ...target])).toContain( + "echo", + ); + }); + + it("forwards an unknown flag after the target to the server", async () => { + const names = await toolNames([ + ...target, + CRASHABLE_FLAG, + "--method", + "tools/list", + ]); + expect(names).toContain("crash_server"); + }); + + it("does not forward a known option placed between target tokens", async () => { + const names = await toolNames([command, "--method", "tools/list", ...args]); + expect(names).toContain("echo"); + expect(names).not.toContain("crash_server"); + }); + + it("appends everything after `--` to the target when an option leads", async () => { + const names = await toolNames([ + "-q", + ...target, + "--method", + "tools/list", + "--", + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + }); + + it("takes the whole target from after a leading `--`", async () => { + const names = await toolNames([ + "--method", + "tools/list", + "--", + ...target, + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + }); + + it("keeps the documented ` -- ` form", async () => { + // Before `--` the target is taken verbatim, flags included; after it come + // the Inspector's options, and an unknown flag there still reaches the + // server. + const names = await toolNames([ + ...target, + "--", + "--method", + "tools/list", + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + + const verbatim = await toolNames([ + ...target, + CRASHABLE_FLAG, + "--", + "--method", + "tools/list", + ]); + expect(verbatim).toContain("crash_server"); + }); + + it("drops unknown flags ahead of the target instead of running them", async () => { + const names = await toolNames([ + "--not-an-inspector-flag", + ...target, + "--method", + "tools/list", + ]); + expect(names).toContain("echo"); + expect(names).not.toContain("crash_server"); + }); +}); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 7c3f41f45a..7f720f7d0f 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { CATALOG_METHODS, emitCompletionIfRequested, @@ -790,6 +790,11 @@ type ParseResult = // output and need no server connection; runCli returns immediately. | { shortCircuit: true }; +/** Commander's own test for "looks like an option": a dash plus at least one character. */ +function isOptionToken(arg: string): boolean { + return arg.length > 1 && arg.startsWith("-"); +} + async function parseArgs(argv?: string[]): Promise { const program = new Command(); // On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead @@ -827,21 +832,39 @@ async function parseArgs(argv?: string[]): Promise { throw err; } }); + // The ad-hoc target is whatever Commander does not consume as one of the + // Inspector's own options (#2631): its positionals plus any unknown flag, in + // their original order. So Inspector options may sit before, between or + // after the target's tokens, and a flag the Inspector does not define + // (`--crashable`) reaches the server. Splitting the target off as the leading + // run of non-dash tokens before parsing — the previous approach — lost the + // whole target as soon as any flag preceded it. + // + // A bare `--` has two readings, chosen by what comes first: + // - ` -- ` (the line starts with a target token): the + // documented CLI form. Everything before `--` is the target verbatim, so a + // server flag that collides with an Inspector option (`--config`) can be + // passed there; what follows is parsed as Inspector options. + // - ` -- ` (the line starts with an option, or with `--`): + // conventional end-of-options. Everything after `--` is appended to the + // target verbatim. The old split read this shape as a target starting with + // a dash, which could never run, so no working invocation changes meaning. const rawArgs = argv ?? process.argv; const scriptArgs = rawArgs.slice(2); const dashDashIndex = scriptArgs.indexOf("--"); - let targetArgs: string[] = []; - let optionArgs: string[]; + let leadingTarget: string[] = []; + let trailingTarget: string[] = []; + let optionArgs = scriptArgs; if (dashDashIndex >= 0) { - targetArgs = scriptArgs.slice(0, dashDashIndex); - optionArgs = scriptArgs.slice(dashDashIndex + 1); - } else { - let i = 0; - while (i < scriptArgs.length && !scriptArgs[i]!.startsWith("-")) { - targetArgs.push(scriptArgs[i]!); - i++; + const before = scriptArgs.slice(0, dashDashIndex); + const after = scriptArgs.slice(dashDashIndex + 1); + if (before.length > 0 && !isOptionToken(before[0]!)) { + leadingTarget = before; + optionArgs = after; + } else { + optionArgs = before; + trailingTarget = after; } - optionArgs = scriptArgs.slice(i); } const preArgs: string[] = [ rawArgs[0] ?? "node", @@ -1079,13 +1102,30 @@ async function parseArgs(argv?: string[]): Promise { .option( "--print-handoff", "Print a JSON handoff block (deepLink, portForwardCmd, oauthStatePath, apiToken) for --server-url and exit. No server connection is made.", - ); + ) + // The launcher's mode flag, accepted and ignored wherever it appears. It + // must be a known option now that unknown flags are forwarded to the + // target (#2631): forwarded, a stray `--cli` would reach a stdio server as + // an argument and make a URL target fail ("Arguments cannot be passed"). + .addOption(new Option("--cli").hideHelp()); registerCompletionOption(program); program.parse(preArgs); // `--completion ` (#2434): print the script and exit, no connect. if (await emitCompletionIfRequested(program)) return { shortCircuit: true }; + // A target cannot begin with a dash, so unknown flags ahead of its first + // token are not server arguments. They are dropped, as every unknown flag + // was before #2631 — callers pass e.g. a stray `--cli` — rather than turned + // into the command, which would make a catalog run a failed ad-hoc launch. + const parsedTarget = [...program.args]; + if (leadingTarget.length === 0) { + while (parsedTarget.length > 0 && isOptionToken(parsedTarget[0]!)) { + parsedTarget.shift(); + } + } + const targetArgs = [...leadingTarget, ...parsedTarget, ...trailingTarget]; + // `--quiet` mutes `console.warn` for the rest of the run; `runCli` restores // it (#2435). Core reports advisories that way from many places the CLI // reaches (the secret-store notice, `roots` / OAuth-endpoint settings it diff --git a/docs/cli-smoke-testing.md b/docs/cli-smoke-testing.md index 4d833a388a..256e4a35e4 100644 --- a/docs/cli-smoke-testing.md +++ b/docs/cli-smoke-testing.md @@ -51,19 +51,23 @@ disconnects without invoking anything. It is the cheapest possible "is the server alive and speaking MCP" assertion, and the right first line of a smoke job. -⚠️ **If your stdio server takes flags of its own, you need a `--` separator, and -it splits the opposite way from the web and TUI clients.** Under `--cli`, -everything **before** `--` is the target command and everything **after** is the -Inspector's own options: +A stdio server's own flags can follow the command directly +(`node build/index.js --verbose --method tools/list`): any flag the Inspector +does not define is passed to the server. ⚠️ **A server flag the Inspector +_also_ defines needs a `--` separator.** When the line starts with the target, +everything **before** `--` is the target command, taken verbatim, and +everything **after** is the Inspector's own options — the opposite of the web +and TUI clients: ```bash npx @modelcontextprotocol/inspector --cli \ node build/index.js --config ./server.conf -- --method tools/list ``` -Without the `--`, the target is only the leading run of non-dash tokens, so -`--config ./server.conf` would be eaten by the Inspector (and rejected as a -conflict with its own `--config` flag). +Without the `--`, `--config ./server.conf` would be eaten by the Inspector (and +rejected as a conflict with its own `--config` flag). The full rules, including +the ` -- ` form, are in +[MCP server configuration](./mcp-server-configuration.md#the----separator). **Always bound the connect.** `--connect-timeout ` defaults to `15000` for ad-hoc `--server-url`/target runs and to the file-level timeout for diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index f4918bfbe7..2884efc370 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -76,14 +76,16 @@ mcp-inspector --server-url https://api.example.com/mcp --transport http Those run as written in the default (web) mode. Under `--cli` two extra rules apply: - A `--method` is required — a CLI invocation with no method exits with `Method is required.` -- **The target must come first.** The CLI reads the leading run of non-dash tokens as the target, so anything after the first flag is no longer part of it: +- **Inspector options may go before or after the target.** The target is every token the CLI does not consume as one of its own options, in order, so these are the same invocation: ```bash - mcp-inspector --cli node build/index.js --method tools/list # ✅ target, then flags - mcp-inspector --cli --method tools/list node build/index.js # ❌ target is dropped + mcp-inspector --cli node build/index.js --method tools/list + mcp-inspector --cli -q --method tools/list node build/index.js ``` - The second form does not error — `node build/index.js` is silently discarded and the Inspector falls back to your catalog, so it "works" against the wrong server. `--tui` has no such rule; Commander parses its arguments in any order. + A flag the Inspector does **not** define (`--verbose`) is part of the target and reaches the server, wherever it sits after the target's first token; one placed _before_ that token is ignored, since a command cannot begin with a dash. A flag the Inspector _does_ define is always the Inspector's — the `--` separator below is how to send one to the server. + + ⚠️ A variadic option (`--tool-arg`, `--prompt-args`, `--header`, `--metadata`, `--tool-metadata`) takes every following non-dash token as a value, so placed directly before the target it swallows the command. Put it after the target, or put another option between the two. Releases before v2.11 required the target to come first and silently dropped it otherwise (#2631). ### The `--` separator @@ -99,19 +101,24 @@ Without the separator, `--config` would be read as the Inspector's own read-only They differ on **when you need it**, though. Web sets `allowUnknownOption()` + `allowExcessArguments()`, so a dash flag the Inspector does not define (`--verbose`) already falls through to the target without a separator — on web `--` is only needed for a flag the Inspector _does_ define. The TUI sets neither, so any unrecognized dash flag is a parse error: on the TUI you need `--` for **every** dash argument meant for the server. -**CLI — reversed: everything _before_ `--` is the server target, everything _after_ is the Inspector's own options.** +**CLI — the direction depends on what the line starts with.** + +When the line **starts with the target**, everything _before_ `--` is the server target, taken verbatim, and everything _after_ is the Inspector's own options: ```bash -mcp-inspector --cli node build/index.js -- --method tools/list +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --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, 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: +That is the way to pass a server flag the Inspector also defines (`--config` here). It is also why the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict): that line starts with the target, so the tokens after `--` are read as Inspector options. + +When the line **starts with an Inspector option**, or with `--` itself, the separator means what it does on web and TUI — everything after it is appended to the target verbatim: ```bash -mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +mcp-inspector --cli -q node build/index.js --method tools/list -- --config /etc/myserver.conf +mcp-inspector --cli --method tools/list -- node build/index.js --config /etc/myserver.conf ``` -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. +A server flag the Inspector does not define needs no separator at all: `mcp-inspector --cli node build/index.js --verbose --method tools/list` passes `--verbose` to the server. ## The shared flags diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 16af7c5317..9c2e34e12e 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -209,16 +209,16 @@ and writes one JSON line to **stderr** on any non-zero exit: **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 +### 2. Before v2.11, the target had to 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: +From v2.11 the CLI tolerates either order again, as v1 did: ```bash -mcp-inspector --cli node build/index.js --method tools/list # ✅ -mcp-inspector --cli --method tools/list node build/index.js # ❌ target silently dropped +mcp-inspector --cli node build/index.js --method tools/list +mcp-inspector --cli --method tools/list node build/index.js ``` -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. +Earlier v2 releases read only the leading run of non-dash tokens as the target, so the second form silently discarded `node build/index.js` and fell back to your catalog — appearing to work against the wrong server. On those releases, put the target first (#2631). ### 3. `--` splits the other way under `--cli` @@ -228,23 +228,26 @@ On **web and TUI**, everything _after_ `--` goes to the target command — same 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. +Under **`--cli`**, when the line **starts with the target**, it is reversed: everything _before_ `--` is the target, everything _after_ is the Inspector's own options. ```bash mcp-inspector --cli node build/index.js -- --method tools/list ``` -So the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). To pass a leading-dash argument through to a stdio server, put it **before** the `--` instead — everything on that side is forwarded to the target untouched, flags included: +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 server flag the Inspector also defines, either put it **before** the `--` — everything on that side is forwarded to the target untouched, flags included — or start the line with an Inspector option, which gives `--` its v1 meaning: ```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 +# v2 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 + +# v2 under --cli, v2.11+ — an option leads, so everything after -- goes to the server +mcp-inspector --cli --method tools/list node build/index.js -- --config /etc/myserver.conf --verbose ``` -(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.) +A flag the Inspector does not define (`--verbose`) needs no separator from v2.11: it reaches the server wherever it follows the target's first token. Earlier v2 releases dropped it unless it sat before a `--`. ### 4. An ambiguous URL path no longer guesses a transport From 6db25fcf867c6a320ce7bdf9926c25f9ed6810f3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:06:30 -0400 Subject: [PATCH 41/73] fix(skills): name each client's own catalog-budget remedies (#2633) The incomplete message for a skill past the run's catalog budget was hard-coded in core with the web's "Skills settings" and the one-shot CLI's `--method skills/get --uri --verify`, so mcpdo and the TUI sent their users to a setting and a command that do not exist there. verifySkills now takes a SkillBudgetHint (where to raise the budget, how to verify one skill alone), defaulting to client-neutral text. The one-shot CLI names --skill-catalog-max-*; mcpdo (via a new runMethod options argument the daemon passes) names the catalog entry and `mcpdo @ skills/get --verify`; the TUI names its launch flags and Enter. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../cli/__tests__/skills-verify-cli.test.ts | 4 + .../__tests__/skills-budget-hint.test.ts | 138 ++++++++++++++++++ clients/mcpdo/src/daemon/server.ts | 24 ++- clients/tui/__tests__/SkillsTab.test.tsx | 5 + clients/tui/src/components/SkillsTab.tsx | 20 ++- .../test/core/mcp/skillsVerification.test.ts | 23 ++- core/cli/handlers/run-method.ts | 24 ++- core/cli/handlers/skills-verify.ts | 28 ++++ core/mcp/skillsVerification.ts | 47 ++++-- 9 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 clients/mcpdo/__tests__/skills-budget-hint.test.ts diff --git a/clients/cli/__tests__/skills-verify-cli.test.ts b/clients/cli/__tests__/skills-verify-cli.test.ts index 704154294e..a3adc63a54 100644 --- a/clients/cli/__tests__/skills-verify-cli.test.ts +++ b/clients/cli/__tests__/skills-verify-cli.test.ts @@ -347,6 +347,10 @@ describe("the catalog-budget escape hatch (#2428)", () => { ); if (!skipped) throw new Error(`no skipped skill in: ${listed.stdout}`); expect(skipped.files).toHaveLength(0); + // The CLI's own way to raise the budget, not another client's (#2633). + expect(skipped.incomplete).toContain( + "--skill-catalog-max-skills / --skill-catalog-max-bytes", + ); const suggested = suggestedArgs(skipped); const got = await runCliCaptured([...target, ...suggested]); diff --git a/clients/mcpdo/__tests__/skills-budget-hint.test.ts b/clients/mcpdo/__tests__/skills-budget-hint.test.ts new file mode 100644 index 0000000000..eb8c09ff74 --- /dev/null +++ b/clients/mcpdo/__tests__/skills-budget-hint.test.ts @@ -0,0 +1,138 @@ +/** + * mcpdo names its OWN remedies for a skill `--verify` left unread under the + * catalog budget (#2633). + * + * The walk is shared core, and the message once hard-coded the web's Skills + * settings and the one-shot CLI's `--method skills/get --uri` — so an mcpdo + * user was sent to a screen and a flag mcpdo does not have. The daemon now + * passes mcpdo's hint into `runMethod`; this runs it end to end and then runs + * the command the message suggests back through mcpdo, since that text is the + * only route the user has to a verdict for the skipped skill. + */ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + createTestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import type { SkillVerifyReport } from "@inspector/core/mcp/skillsVerification.js"; +import { runMcp, type McpResult } from "./helpers/mcp-runner.js"; +import { + createTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; +import { callDaemon } from "../src/daemon/client.js"; + +/** + * The reports of a `--verify` run under `--format json`, which mcpdo prints as + * one JSON array — or a failure naming what the run printed instead. + */ +function reportsOf(result: McpResult): SkillVerifyReport[] { + try { + return JSON.parse(result.stdout) as SkillVerifyReport[]; + } catch { + throw new Error( + `not a --verify report (exit ${result.exitCode}):\n${result.output}`, + ); + } +} + +describe("mcpdo skills catalog-budget hint (#2633)", () => { + let storageDir: string | undefined; + let configPath: string | undefined; + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "mcpdod.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("names mcpdo's remedies, and the suggested command verifies the skipped skill", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo("skills-budget", "1.0.0"), + skills: true, + }); + try { + await server.start(); + configPath = createTestConfig({ + mcpServers: { + skills: { + type: "streamable-http", + url: server.url, + skillCatalogMaxSkills: 1, + }, + }, + }); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpdo-budget-")); + const env = { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + }; + + expectCliSuccess( + await runMcp( + ["connect", "skills", "--config", configPath, "--format", "json"], + { env, timeout: 20000 }, + ), + ); + + const listed = await runMcp( + ["@skills", "skills/list", "--verify", "--format", "json"], + { env, timeout: 20000 }, + ); + const skipped = reportsOf(listed).find((report) => + report.incomplete?.includes("catalog budget"), + ); + if (!skipped) throw new Error(`no skipped skill in: ${listed.output}`); + // mcpdo's own remedies — not the web's settings screen, nor the + // one-shot CLI's flags and `--method` syntax. + expect(skipped.incomplete).toContain( + "raise it with skillCatalogMaxSkills / skillCatalogMaxBytes in the connection's catalog entry", + ); + expect(skipped.incomplete).toContain( + "`mcpdo @ skills/get --verify`", + ); + expect(skipped.incomplete).not.toMatch( + /--method|--skill-catalog-max|Skills settings/, + ); + expect(skipped.incomplete).not.toContain(skipped.uri); + + // Run the suggested command back, placeholders filled the way a user + // would fill them, minus the `mcpdo` program name itself. + const command = /`([^`]+)`/.exec(skipped.incomplete ?? "")?.[1] ?? ""; + const argv = command + .split(/\s+/) + .slice(1) + .map((arg) => + arg === "@" ? "@skills" : arg === "" ? skipped.uri : arg, + ); + const got = await runMcp([...argv, "--format", "json"], { + env, + timeout: 20000, + }); + const reports = reportsOf(got); + expect(reports).toHaveLength(1); + expect(reports[0].uri).toBe(skipped.uri); + expect(reports[0].incomplete ?? "").not.toMatch(/catalog budget/); + expect(reports[0].files.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }); +}); diff --git a/clients/mcpdo/src/daemon/server.ts b/clients/mcpdo/src/daemon/server.ts index 7b45e83709..dfc4789c7d 100644 --- a/clients/mcpdo/src/daemon/server.ts +++ b/clients/mcpdo/src/daemon/server.ts @@ -5,7 +5,11 @@ import { CliExitCodeError, EXIT_CODES, } from "@inspector/core/cli/error-handler.js"; -import { runMethod } from "@inspector/core/cli/handlers/run-method.js"; +import { + runMethod, + type RunMethodOptions, +} from "@inspector/core/cli/handlers/run-method.js"; +import { MCPDO_SKILL_BUDGET_HINT } from "@inspector/core/cli/handlers/skills-verify.js"; import type { MethodArgs } from "@inspector/core/cli/handlers/method-types.js"; import { acceptDaemonConnection, @@ -51,6 +55,15 @@ import { ConnectionRegistry, } from "./connections.js"; +/** + * What every `runMethod` call from the daemon contributes on mcpdo's behalf: + * its own remedies for a skill `--verify` left unread under the catalog + * budget, rather than the one-shot CLI's flags and syntax (#2633). + */ +const MCPDO_RUN_OPTIONS: RunMethodOptions = { + skillBudgetHint: MCPDO_SKILL_BUDGET_HINT, +}; + /** * Default channel used when a caller doesn't wire a real one (in-process * `handle`/`handleOutcome` test call sites that predate elicitation support). @@ -661,7 +674,7 @@ export class DaemonServer { const clearAmbientSignal = client.setAmbientRequestSignal(signal); let outcome; try { - outcome = await runMethod(client, methodArgs); + outcome = await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS); } finally { clearAmbientSignal(); signal?.removeEventListener("abort", onAbort); @@ -712,7 +725,10 @@ export class DaemonServer { const unwire = wireElicitationBridge(client, channel, requestId); const outcome: Promise = (async () => { try { - return toRpcResult(await runMethod(client, methodArgs), params.method); + return toRpcResult( + await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS), + params.method, + ); } finally { unwire(); } @@ -847,7 +863,7 @@ export class DaemonServer { params.interactive, ); const methodArgs = stripConnectionFields(params); - const outcome = await runMethod(client, methodArgs); + const outcome = await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS); if (outcome.kind !== "stream") { throw new CliExitCodeError( EXIT_CODES.USAGE, diff --git a/clients/tui/__tests__/SkillsTab.test.tsx b/clients/tui/__tests__/SkillsTab.test.tsx index eb95a02e3a..1efe5faa1c 100644 --- a/clients/tui/__tests__/SkillsTab.test.tsx +++ b/clients/tui/__tests__/SkillsTab.test.tsx @@ -997,6 +997,11 @@ describe("SkillsTab verify all (#2590)", () => { expect(frame).toContain("Incomplete:"); expect(frame).toContain("catalog budget of 1 skills"); expect(frame).toContain("Verification INCOMPLETE"); + // The pane's own remedies — its launch flags and Enter — not the web's + // settings screen or the one-shot CLI's syntax (#2633). + expect(frame).toContain("--skill-catalog-max-skills"); + expect(frame).toContain("by selecting it and pressing Enter"); + expect(frame).not.toContain("--method"); }); it("covers the whole catalog even while a filter narrows the list", async () => { diff --git a/clients/tui/src/components/SkillsTab.tsx b/clients/tui/src/components/SkillsTab.tsx index 7966f630b0..361140db83 100644 --- a/clients/tui/src/components/SkillsTab.tsx +++ b/clients/tui/src/components/SkillsTab.tsx @@ -43,6 +43,7 @@ import { } from "@inspector/core/mcp/skillsSchemas.js"; import { verifySkills, + type SkillBudgetHint, type SkillFileReport, type SkillVerifyReport, } from "@inspector/core/mcp/skillsVerification.js"; @@ -115,6 +116,18 @@ const FILE_COLOR: Record = { "read-error": "red", }; +/** + * The pane's remedies for a skill `v` left unread under the catalog budget + * (#2633): the TUI raises it with its launch flags, and verifies one skill with + * Enter — which always reads that skill in full, since a run of one is never + * past the budget. + */ +const TUI_SKILL_BUDGET_HINT: SkillBudgetHint = { + raise: + "with --skill-catalog-max-skills / --skill-catalog-max-bytes at launch", + verifyOne: "by selecting it and pressing Enter", +}; + /** * The status line for each verification outcome. * @@ -318,7 +331,12 @@ export function SkillsTab({ // this key handler — which cannot await — to own. void (async () => { try { - const results = await verifySkills(inspectorClient, entries); + const results = await verifySkills( + inspectorClient, + entries, + undefined, + TUI_SKILL_BUDGET_HINT, + ); // Read AFTER the run, so a listing that changed while it was in // flight is the one pruned against. const live = new Set(skillsRef.current.map(skillEntryKey)); diff --git a/clients/web/src/test/core/mcp/skillsVerification.test.ts b/clients/web/src/test/core/mcp/skillsVerification.test.ts index 5c848244a7..5ac858676a 100644 --- a/clients/web/src/test/core/mcp/skillsVerification.test.ts +++ b/clients/web/src/test/core/mcp/skillsVerification.test.ts @@ -13,6 +13,7 @@ import { allSkillsVerified, anySkillFailed, anySkillUnverifiable, + DEFAULT_SKILL_BUDGET_HINT, utf8Length, verifySkills, } from "@inspector/core/mcp/skillsVerification.js"; @@ -793,12 +794,16 @@ describe("verifySkills (#2248)", () => { for (const report of past) { expect(report.outcome).toBe("incomplete"); expect(report.incomplete).toMatch(/catalog budget/); - // The escape hatch carries the `--verify` that produces a verdict — - // `--uri` alone fetches the skill and checks nothing (#2428) — and a - // placeholder rather than the server-controlled URI itself. + // With no hint passed, the remedies are client-neutral rather than + // some other client's commands (#2633) — and never the + // server-controlled URI itself. expect(report.incomplete).toContain( - "`--method skills/get --uri --verify`", + `raise it ${DEFAULT_SKILL_BUDGET_HINT.raise}`, ); + expect(report.incomplete).toContain( + `verify it on its own ${DEFAULT_SKILL_BUDGET_HINT.verifyOne} to get a verdict`, + ); + expect(report.incomplete).not.toMatch(/--method|Skills settings/); expect(report.incomplete).not.toContain(report.uri); expect(report.files).toHaveLength(0); } @@ -837,7 +842,10 @@ describe("verifySkills (#2248)", () => { readResource, getServerSettings: () => ({ skillCatalogMaxSkills: 2 }), } as unknown as InspectorClientProtocol; - const reports = await verifySkills(client, skills); + const reports = await verifySkills(client, skills, undefined, { + raise: "", + verifyOne: "", + }); expect(readResource.mock.calls.length).toBe(2); expect(reports.map((r) => r.outcome)).toEqual([ "verified", @@ -847,6 +855,11 @@ describe("verifySkills (#2248)", () => { ]); // The reason names the limit that actually applied, not the default. expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/); + // …and the caller's own remedies, in place of any default (#2633). + expect(reports[2].incomplete).toContain("(raise it )"); + expect(reports[2].incomplete).toContain( + "verify it on its own to get a verdict.", + ); }); it("honors a configured catalog BYTE budget (#2294)", async () => { diff --git a/core/cli/handlers/run-method.ts b/core/cli/handlers/run-method.ts index e8ae570db9..6e1e1d7872 100644 --- a/core/cli/handlers/run-method.ts +++ b/core/cli/handlers/run-method.ts @@ -13,10 +13,14 @@ import { SKILLS_EXTENSION_KEY } from "../../mcp/skillsSchemas.js"; import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; import { collectAppInfo } from "./collect-app-info.js"; import { + CLI_SKILL_BUDGET_HINT, skillVerificationExitCode, summarizeSkillVerification, } from "./skills-verify.js"; -import { verifySkills } from "../../mcp/skillsVerification.js"; +import { + verifySkills, + type SkillBudgetHint, +} from "../../mcp/skillsVerification.js"; import type { CliAppInfo, McpResponse, @@ -91,6 +95,20 @@ type SharedResourceSubscription = { ready: Promise; }; +/** + * What the calling client contributes to {@link runMethod} beyond the request + * itself. Kept out of {@link MethodArgs} deliberately: mcpdo builds those from + * the RPC params its caller sent, and how a client names its own remedies is + * not something a caller should be able to supply. + */ +export type RunMethodOptions = { + /** + * The remedies `--verify` names for a skill past the catalog budget + * (#2633). Defaults to the one-shot CLI's. + */ + skillBudgetHint?: SkillBudgetHint; +}; + /** * Run one MCP method against a connected {@link InspectorClient}. * Core method dispatch used by the CLI (and other Inspector Node runners). @@ -102,7 +120,9 @@ type SharedResourceSubscription = { export async function runMethod( inspectorClient: InspectorClient, args: MethodArgs & { method: string }, + options: RunMethodOptions = {}, ): Promise { + const skillBudgetHint = options.skillBudgetHint ?? CLI_SKILL_BUDGET_HINT; let managedToolsState: ManagedToolsState | null = null; let managedResourcesState: ManagedResourcesState | null = null; let managedResourceTemplatesState: ManagedResourceTemplatesState | null = @@ -471,6 +491,7 @@ export async function runMethod( inspectorClient, skills, args.metadata, + skillBudgetHint, ); return { kind: "ndjson", @@ -509,6 +530,7 @@ export async function runMethod( inspectorClient, [skill], args.metadata, + skillBudgetHint, ); return { kind: "ndjson", diff --git a/core/cli/handlers/skills-verify.ts b/core/cli/handlers/skills-verify.ts index dc8148af8a..be49f8b9a6 100644 --- a/core/cli/handlers/skills-verify.ts +++ b/core/cli/handlers/skills-verify.ts @@ -16,10 +16,38 @@ import { allSkillsVerified, anySkillFailed, anySkillUnverifiable, + type SkillBudgetHint, type SkillVerifyReport, } from "../../mcp/skillsVerification.js"; import { EXIT_CODES } from "../error-handler.js"; +/** + * The one-shot CLI's remedies for a skill past the catalog budget (#2633). + * + * ⚠️ The command carries `--verify`. It once named only + * `--method skills/get --uri`, which on its own prints the skill and checks + * nothing — so the escape hatch returned no verdict at all (#2428). + * `skills-verify-cli.test.ts` runs the command back through the CLI. + */ +export const CLI_SKILL_BUDGET_HINT: SkillBudgetHint = { + raise: + "with --skill-catalog-max-skills / --skill-catalog-max-bytes, or the server's skillCatalogMaxSkills / skillCatalogMaxBytes", + verifyOne: + "with `--method skills/get --uri --verify`, where is this report's uri,", +}; + +/** + * mcpdo's remedies (#2633). It has no `--skill-catalog-max-*` flags — the + * daemon holds the connection, so the budget comes from the catalog entry — + * and it takes the skill URI positionally on a named connection. + */ +export const MCPDO_SKILL_BUDGET_HINT: SkillBudgetHint = { + raise: + "with skillCatalogMaxSkills / skillCatalogMaxBytes in the connection's catalog entry", + verifyOne: + "with `mcpdo @ skills/get --verify`, where is this report's uri,", +}; + /** * The exit code for a `--verify` run, or `undefined` for success. * diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts index bb91e7f1f0..dc570a9713 100644 --- a/core/mcp/skillsVerification.ts +++ b/core/mcp/skillsVerification.ts @@ -48,6 +48,39 @@ import { type SkillResource, } from "./skillsSchemas.js"; +/** + * What a client tells its user to do about a skill the run's catalog budget + * left unread (#2633). + * + * The budget is shared core, but its two remedies are not: each client raises + * the budget in its own place and verifies one skill with its own syntax. A + * single hard-coded message named the web's Skills settings and the one-shot + * CLI's `--method skills/get` in every client, which sent an mcpdo or TUI user + * to a setting and a command that do not exist where they are. So the walk + * owns the sentence and each caller supplies the two clauses. + * + * ⚠️ Neither clause may splice in the skill's URI. It is server-controlled, and + * a command built from it is one a server wrote for the user to paste — a + * command names it with a `` placeholder, as the report already carries it. + */ +export interface SkillBudgetHint { + /** Where the budget is raised; completes "raise it …". */ + raise: string; + /** How to verify one skill on its own; completes "verify it on its own …". */ + verifyOne: string; +} + +/** + * The client-neutral remedies, for a caller that names none. Every shipped + * client passes its own; this only keeps a new caller from inheriting another + * client's commands. + */ +export const DEFAULT_SKILL_BUDGET_HINT: SkillBudgetHint = { + raise: + "with the server's skillCatalogMaxSkills / skillCatalogMaxBytes settings", + verifyOne: "by verifying this skill alone", +}; + /** One manifest entry's outcome. `read-error` means the fetch itself failed. */ export type SkillFileStatus = SkillVerification["status"] | "read-error"; @@ -316,6 +349,7 @@ export async function verifySkills( client: InspectorClientProtocol, entries: readonly SkillEntry[], metadata?: RequestMetadata, + budgetHint: SkillBudgetHint = DEFAULT_SKILL_BUDGET_HINT, ): Promise { // Computed once over the whole set, because a name collision is a property // of the listing rather than of an entry — `checkSkillConformance` sees one @@ -386,18 +420,9 @@ export async function verifySkills( // running total would CROSS the limit, so a conforming skill (≤ 16 MiB in // total, by definition) is never truncated. const manifest = withinBudget ? boundedManifest(declared) : []; - // ⚠️ The suggested command carries `--verify`. It named only - // `--method skills/get --uri`, which on its own prints the skill and - // checks nothing — so the escape hatch this message advertises returned - // no verdict at all (#2428). `skills-verify-cli.test.ts` runs the command - // back through the CLI. - // - // ⚠️ The URI is a `` placeholder, never `entry.uri` spliced in. The - // URI is server-controlled and shell metacharacters are legal in one, so - // a command built from it is a command a server wrote for the user to - // paste (Copilot). The report already carries the URI in its own `uri`. + // The remedies are the caller's — see {@link SkillBudgetHint} (#2633). let incomplete = !withinBudget - ? `Not read: this run already reached its catalog budget of ${budget.maxSkills} skills / ${budget.maxBytes} bytes (raise it in the server's Skills settings). Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri --verify\`, where is this report's uri, to get a verdict.` + ? `Not read: this run already reached its catalog budget of ${budget.maxSkills} skills / ${budget.maxBytes} bytes (raise it ${budgetHint.raise}). Nothing about this skill's files has been checked — verify it on its own ${budgetHint.verifyOne} to get a verdict.` : manifest.length < declared.length ? `Only ${manifest.length} of ${declared.length} manifest entries were read: the skill exceeds the ${SKILL_MAX_RESOURCE_ENTRIES}-entry / ${SKILL_MAX_TOTAL_BYTES}-byte interoperability limits, so the rest were not fetched and cannot be reported on.` : undefined; From 50a057bf84e49807b71d8a0f9708ae0cb366f848 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:11:41 -0400 Subject: [PATCH 42/73] test(mcpdo): give the budget-hint test an outer timeout above its command budgets (#2633) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../__tests__/skills-budget-hint.test.ts | 150 ++++++++++-------- 1 file changed, 82 insertions(+), 68 deletions(-) diff --git a/clients/mcpdo/__tests__/skills-budget-hint.test.ts b/clients/mcpdo/__tests__/skills-budget-hint.test.ts index eb8c09ff74..6f26427fd0 100644 --- a/clients/mcpdo/__tests__/skills-budget-hint.test.ts +++ b/clients/mcpdo/__tests__/skills-budget-hint.test.ts @@ -26,6 +26,16 @@ import { import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; import { callDaemon } from "../src/daemon/client.js"; +/** Budget for each mcpdo command, matching the sibling suites' `runMcp` calls. */ +const COMMAND_TIMEOUT_MS = 20_000; +/** + * The test runs three commands in sequence, so its own budget must exceed all + * three together — otherwise Vitest's 15s default cuts in before a slow + * command's own timer can name it (an inner budget must be the tighter one; + * see AGENTS.md "Test-gate timeouts"), plus headroom for the HTTP server. + */ +const TEST_TIMEOUT_MS = 3 * COMMAND_TIMEOUT_MS + 10_000; + /** * The reports of a `--verify` run under `--format json`, which mcpdo prints as * one JSON array — or a failure naming what the run printed instead. @@ -63,76 +73,80 @@ describe("mcpdo skills catalog-budget hint (#2633)", () => { } }); - it("names mcpdo's remedies, and the suggested command verifies the skipped skill", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo("skills-budget", "1.0.0"), - skills: true, - }); - try { - await server.start(); - configPath = createTestConfig({ - mcpServers: { - skills: { - type: "streamable-http", - url: server.url, - skillCatalogMaxSkills: 1, - }, - }, + it( + "names mcpdo's remedies, and the suggested command verifies the skipped skill", + async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo("skills-budget", "1.0.0"), + skills: true, }); - storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpdo-budget-")); - const env = { - MCP_STORAGE_DIR: storageDir, - MCP_INSPECTOR_DAEMON_DIR: storageDir, - }; - - expectCliSuccess( - await runMcp( - ["connect", "skills", "--config", configPath, "--format", "json"], - { env, timeout: 20000 }, - ), - ); + try { + await server.start(); + configPath = createTestConfig({ + mcpServers: { + skills: { + type: "streamable-http", + url: server.url, + skillCatalogMaxSkills: 1, + }, + }, + }); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpdo-budget-")); + const env = { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + }; - const listed = await runMcp( - ["@skills", "skills/list", "--verify", "--format", "json"], - { env, timeout: 20000 }, - ); - const skipped = reportsOf(listed).find((report) => - report.incomplete?.includes("catalog budget"), - ); - if (!skipped) throw new Error(`no skipped skill in: ${listed.output}`); - // mcpdo's own remedies — not the web's settings screen, nor the - // one-shot CLI's flags and `--method` syntax. - expect(skipped.incomplete).toContain( - "raise it with skillCatalogMaxSkills / skillCatalogMaxBytes in the connection's catalog entry", - ); - expect(skipped.incomplete).toContain( - "`mcpdo @ skills/get --verify`", - ); - expect(skipped.incomplete).not.toMatch( - /--method|--skill-catalog-max|Skills settings/, - ); - expect(skipped.incomplete).not.toContain(skipped.uri); + expectCliSuccess( + await runMcp( + ["connect", "skills", "--config", configPath, "--format", "json"], + { env, timeout: COMMAND_TIMEOUT_MS }, + ), + ); - // Run the suggested command back, placeholders filled the way a user - // would fill them, minus the `mcpdo` program name itself. - const command = /`([^`]+)`/.exec(skipped.incomplete ?? "")?.[1] ?? ""; - const argv = command - .split(/\s+/) - .slice(1) - .map((arg) => - arg === "@" ? "@skills" : arg === "" ? skipped.uri : arg, + const listed = await runMcp( + ["@skills", "skills/list", "--verify", "--format", "json"], + { env, timeout: COMMAND_TIMEOUT_MS }, ); - const got = await runMcp([...argv, "--format", "json"], { - env, - timeout: 20000, - }); - const reports = reportsOf(got); - expect(reports).toHaveLength(1); - expect(reports[0].uri).toBe(skipped.uri); - expect(reports[0].incomplete ?? "").not.toMatch(/catalog budget/); - expect(reports[0].files.length).toBeGreaterThan(0); - } finally { - await server.stop(); - } - }); + const skipped = reportsOf(listed).find((report) => + report.incomplete?.includes("catalog budget"), + ); + if (!skipped) throw new Error(`no skipped skill in: ${listed.output}`); + // mcpdo's own remedies — not the web's settings screen, nor the + // one-shot CLI's flags and `--method` syntax. + expect(skipped.incomplete).toContain( + "raise it with skillCatalogMaxSkills / skillCatalogMaxBytes in the connection's catalog entry", + ); + expect(skipped.incomplete).toContain( + "`mcpdo @ skills/get --verify`", + ); + expect(skipped.incomplete).not.toMatch( + /--method|--skill-catalog-max|Skills settings/, + ); + expect(skipped.incomplete).not.toContain(skipped.uri); + + // Run the suggested command back, placeholders filled the way a user + // would fill them, minus the `mcpdo` program name itself. + const command = /`([^`]+)`/.exec(skipped.incomplete ?? "")?.[1] ?? ""; + const argv = command + .split(/\s+/) + .slice(1) + .map((arg) => + arg === "@" ? "@skills" : arg === "" ? skipped.uri : arg, + ); + const got = await runMcp([...argv, "--format", "json"], { + env, + timeout: COMMAND_TIMEOUT_MS, + }); + const reports = reportsOf(got); + expect(reports).toHaveLength(1); + expect(reports[0].uri).toBe(skipped.uri); + expect(reports[0].incomplete ?? "").not.toMatch(/catalog budget/); + expect(reports[0].files.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }, + TEST_TIMEOUT_MS, + ); }); From aee893f736816ed6d3506e5f7eb0c3dea4c222fd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:16:17 -0400 Subject: [PATCH 43/73] test(mcpdo): wait for the daemon socket to close before removing its dir (#2633) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/mcpdo/__tests__/skills-budget-hint.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/clients/mcpdo/__tests__/skills-budget-hint.test.ts b/clients/mcpdo/__tests__/skills-budget-hint.test.ts index 6f26427fd0..6ff17c7043 100644 --- a/clients/mcpdo/__tests__/skills-budget-hint.test.ts +++ b/clients/mcpdo/__tests__/skills-budget-hint.test.ts @@ -63,6 +63,13 @@ describe("mcpdo skills catalog-budget hint (#2633)", () => { } catch { // already stopped } + // `daemon/stop` answers before shutdown finishes, so wait for the + // socket to go before deleting the directory a live daemon still + // holds — the same wait `mcp-coverage.test.ts` makes. + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } } fs.rmSync(storageDir, { recursive: true, force: true }); storageDir = undefined; From f3906923b17da1cf9f566925e3d3d8730ae192e3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:20:39 -0400 Subject: [PATCH 44/73] fix(cli): refuse a URL that contradicts a positional command or --transport stdio (#2611) buildConfigFromOptions resolved the target as `positional URL ?? --server-url` and returned on the URL branch, so a positional command next to --server-url was silently dropped, and --transport stdio next to a URL was ignored (or reported as "not specified" when the path gave no hint). Decide the target before the transport and refuse contradictions: - a positional target together with --server-url is an error naming both - --transport stdio with a URL is an error - an unrecognized --transport is an error; /mcp and /sse path sniffing runs only when --transport is absent The CLI README's first remote example no longer claims an SSE default and now runs as written. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/README.md | 6 +- .../web/src/test/core/mcp/node/config.test.ts | 68 +++++++++++++++++++ core/mcp/node/config.ts | 29 +++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index e6c53b6a77..38dfb9a3cd 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -62,12 +62,14 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l You can also connect to remote MCP servers using HTTP or SSE transports. -**Connect to a remote MCP server (default is SSE)** +**Connect to a remote MCP server (transport inferred from a `/mcp` or `/sse` path)** ```bash -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com/mcp --method tools/list ``` +There is no default transport: a URL whose path ends in neither `/mcp` nor `/sse` needs an explicit `--transport http` or `--transport sse`, as below. A target is either a positional command/URL or `--server-url`, never both, and `--transport stdio` cannot be used with a URL — either combination is refused rather than resolved by precedence. + **Connect with Streamable HTTP transport** ```bash diff --git a/clients/web/src/test/core/mcp/node/config.test.ts b/clients/web/src/test/core/mcp/node/config.test.ts index d107e985b8..83dff99e65 100644 --- a/clients/web/src/test/core/mcp/node/config.test.ts +++ b/clients/web/src/test/core/mcp/node/config.test.ts @@ -20,6 +20,7 @@ import { serverSourceConflict, readServerListFile, getNamedServerConfigs, + type ServerConfigOptions, } from "@inspector/core/mcp/node/config.js"; describe("parseKeyValuePair", () => { @@ -467,6 +468,73 @@ describe("resolveServerConfigs — single mode", () => { ).toThrow(/Only stdio transport can be used with local commands/); }); + it("rejects a positional command combined with --server-url (#2611)", () => { + expect(() => + resolveServerConfigs( + { + target: ["node", "./server.js"], + serverUrl: "https://example.com/mcp", + }, + "single", + ), + ).toThrow( + /Cannot combine a positional target \(node\) with --server-url \(https:\/\/example\.com\/mcp\)/, + ); + }); + + it("rejects a positional URL combined with --server-url", () => { + expect(() => + resolveServerConfigs( + { + target: ["https://a.example.com/mcp"], + serverUrl: "https://b.example.com/mcp", + }, + "single", + ), + ).toThrow(/Cannot combine a positional target/); + }); + + it("rejects --transport stdio with a --server-url ending in /mcp", () => { + expect(() => + resolveServerConfigs( + { transport: "stdio", serverUrl: "https://example.com/mcp" }, + "single", + ), + ).toThrow(/stdio transport cannot be used with a URL/); + }); + + it("rejects --transport stdio with an unhinted URL rather than calling it unspecified", () => { + expect(() => + resolveServerConfigs( + { transport: "stdio", target: ["https://example.com/foo"] }, + "single", + ), + ).toThrow(/stdio transport cannot be used with a URL/); + }); + + it("rejects an unrecognized transport with a URL instead of sniffing the path", () => { + // The web launcher passes --transport through unvalidated, so an + // arbitrary string can reach the builder at runtime. + const transport: string = "bogus"; + expect(() => + resolveServerConfigs( + { + transport: transport as ServerConfigOptions["transport"], + serverUrl: "https://example.com/mcp", + }, + "single", + ), + ).toThrow(/Invalid transport type: bogus/); + }); + + it("respects an explicit --transport sse over a /mcp path", () => { + const [config] = resolveServerConfigs( + { transport: "sse", serverUrl: "https://example.com/mcp" }, + "single", + ); + expect(config).toEqual({ type: "sse", url: "https://example.com/mcp" }); + }); + it("requires a target when no config path is given", () => { expect(() => resolveServerConfigs({ target: [] }, "single")).toThrow( /Target is required/, diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index 09ca1a2a78..00b7b45572 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -223,12 +223,28 @@ function loadServerFromConfig( return server; } -/** Build one MCPServerConfig from ad-hoc options (no config file). */ +/** + * Build one MCPServerConfig from ad-hoc options (no config file). + * + * The target is decided before the transport, and a contradiction between + * flags is refused rather than resolved by precedence (#2611): a positional + * target next to `--server-url` names two servers, and `--transport stdio` + * next to a URL names a transport that URL cannot use. Either used to be + * dropped silently in favour of the URL. Path sniffing (`/mcp`, `/sse`) runs + * only when `--transport` was absent, so a given transport is never reported + * as missing. + */ function buildConfigFromOptions(options: ServerConfigOptions): MCPServerConfig { const target = options.target ?? []; const first = target[0]; const rest = target.slice(1); + if (first && options.serverUrl) { + throw new Error( + `Cannot combine a positional target (${first}) with --server-url (${options.serverUrl}). Pass one or the other.`, + ); + } + const urlFromTarget = first && (first.startsWith("http://") || first.startsWith("https://")) ? first @@ -236,14 +252,23 @@ function buildConfigFromOptions(options: ServerConfigOptions): MCPServerConfig { const url = urlFromTarget ?? options.serverUrl ?? null; if (url) { - if (rest.length > 0 && urlFromTarget) { + if (rest.length > 0) { throw new Error("Arguments cannot be passed to a URL-based MCP server."); } + if (options.transport === "stdio") { + throw new Error( + `stdio transport cannot be used with a URL (${url}). Use --transport http or --transport sse, or pass a local command instead.`, + ); + } let transportType: "sse" | "streamable-http"; const t = options.transport === "http" ? "streamable-http" : options.transport; if (t === "sse" || t === "streamable-http") { transportType = t; + } else if (t) { + throw new Error( + `Invalid transport type: ${t}. Valid types are: sse, http, stdio`, + ); } else { const u = new URL(url); if (u.pathname.endsWith("/mcp")) { From 801eeb53ad495eb6a4e1fdc2c5bbc4a6fc3df3a1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:25:27 -0400 Subject: [PATCH 45/73] fix(cli): suppress the --output "Wrote N bytes" status line under -q (#2614) In text mode, `--output` confirms the write with a status line on stderr. `-q` is documented to suppress status lines, but this branch never checked `args.quiet`. Gate it, and cover both paths in emit-result.test.ts. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/README.md | 2 +- clients/cli/__tests__/emit-result.test.ts | 55 +++++++++++++++++++++++ clients/cli/src/handlers/emit-result.ts | 10 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index e6c53b6a77..ce080dbb41 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -279,7 +279,7 @@ mcp-inspector --cli --method tools/call --tool-name summarize --output mcp-inspector --cli --method tools/call --tool-name render_chart --output chart.png --output-format raw ``` -The flag is `--output-format` rather than a new `--format` value because `--format` already shapes **stdout**, and the two compose: in text mode stdout stays empty and a one-line `Wrote N bytes (json) to ` confirmation goes to stderr; under `--format json` stdout still carries exactly one envelope, with `output` in place of `result` — `{"output":{"path":"echo.json","format":"json","bytes":123}}`, plus `appInfo` / `schemaFindings` when they would otherwise appear. +The flag is `--output-format` rather than a new `--format` value because `--format` already shapes **stdout**, and the two compose: in text mode stdout stays empty and a one-line `Wrote N bytes (json) to ` confirmation goes to stderr (suppressed by `-q`, like any other status line); under `--format json` stdout still carries exactly one envelope, with `output` in place of `result` — `{"output":{"path":"echo.json","format":"json","bytes":123}}`, plus `appInfo` / `schemaFindings` when they would otherwise appear. Exit codes are unchanged: a `tools/call` that returns `isError:true` is still written and still exits `5`. A file that cannot be written (missing directory, no permission) exits `1` with envelope code `output_write_failed`. diff --git a/clients/cli/__tests__/emit-result.test.ts b/clients/cli/__tests__/emit-result.test.ts index 1776e385ce..f52d1731ca 100644 --- a/clients/cli/__tests__/emit-result.test.ts +++ b/clients/cli/__tests__/emit-result.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { emitResult, collectAppInfo } from "../src/cli.js"; import { CliExitCodeError } from "@inspector/core/cli/error-handler.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; @@ -80,6 +83,58 @@ describe("emitResult", () => { }); }); +/** + * `--output` in text mode confirms the write with a status line on stderr, + * which `-q` must suppress like every other status line (#2614). + */ +describe("emitResult --output status line", () => { + let dir: string; + let stderr: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "inspector-cli-emit-")); + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]): boolean => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + rmSync(dir, { recursive: true, force: true }); + }); + + it("prints the Wrote line to stderr without -q", async () => { + const path = join(dir, "out.json"); + await emitResult({ ok: true }, undefined, { + method: "tools/call", + format: "text", + output: path, + }); + expect(stderr).toMatch(/^Wrote \d+ bytes \(json\) to .*out\.json\n$/); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ ok: true }); + }); + + it("suppresses the Wrote line under -q but still writes the file", async () => { + const path = join(dir, "out.json"); + await emitResult({ ok: true }, undefined, { + method: "tools/call", + format: "text", + output: path, + quiet: true, + }); + expect(stderr).toBe(""); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ ok: true }); + }); +}); + describe("collectAppInfo", () => { it("returns base info for a non-App tool without reading a resource", async () => { const client = { diff --git a/clients/cli/src/handlers/emit-result.ts b/clients/cli/src/handlers/emit-result.ts index e8488ad715..4339d65211 100644 --- a/clients/cli/src/handlers/emit-result.ts +++ b/clients/cli/src/handlers/emit-result.ts @@ -52,7 +52,7 @@ export async function emitResult( // `curl -o`. Under `--format json` stdout still carries exactly one envelope — // `{ output }` in place of `{ result }` — so a caller parsing it learns where // the result went; in text mode the confirmation goes to stderr, leaving - // stdout empty. + // stdout empty. That confirmation is a status line, so `-q` drops it (#2614). const written = args.output ? await writeResultFile( result, @@ -70,9 +70,11 @@ export async function emitResult( if (args.strict && lint && lint.length > 0) envelope.schemaFindings = lint; await awaitableLog(JSON.stringify(envelope) + "\n"); } else if (written) { - await awaitableError( - `Wrote ${written.bytes} bytes (${written.format}) to ${written.path}\n`, - ); + if (!args.quiet) { + await awaitableError( + `Wrote ${written.bytes} bytes (${written.format}) to ${written.path}\n`, + ); + } } else { await awaitableLog(JSON.stringify(result, null, 2) + "\n"); } From a44dcbabaa69d4791776c5afd131a7b301b8f84b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:34:47 -0400 Subject: [PATCH 46/73] fix(cli,mcpdo,tui): name each client's own remedy on a connect timeout (#2615) The connect-time timeout error told every client's user to raise the timeout "in Server Settings", a form only the web client has. Core keeps the message and now takes the remedy sentence from the client via a new `connectionTimeoutRemedy` option on InspectorClient (default: the web wording). The one-shot CLI and mcpdo point at --connect-timeout or the server entry's connectionTimeout in mcp.json; the TUI, which has no timeout flag, points at mcp.json only. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/error-handler.test.ts | 3 +- .../__tests__/programmatic-ergonomics.test.ts | 36 +++++++++++++++++++ clients/cli/src/cli.ts | 4 +++ .../mcpdo/__tests__/daemon-coverage.test.ts | 35 ++++++++++++++++++ clients/mcpdo/src/connection/authorize.ts | 3 ++ clients/mcpdo/src/daemon/connections.ts | 4 +++ clients/tui/__tests__/App.test.tsx | 34 +++++++++++++++--- clients/tui/src/App.tsx | 9 +++++ .../integration/mcp/inspectorClient.test.ts | 31 ++++++++++++++++ core/cli/handlers/connect-timeout.ts | 8 +++++ core/mcp/inspectorClient.ts | 36 ++++++++++++++----- core/mcp/types.ts | 16 +++++++++ 12 files changed, 205 insertions(+), 14 deletions(-) diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index b08a9d28d4..6997eaab48 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -206,7 +206,8 @@ describe("classifyError", () => { // rest of the network-layer failures. const err = new Error( "Connection timed out after 30000 ms. To accommodate a slower server, " + - "you may increase the timeout value in Server Settings.", + "pass a larger --connect-timeout , or set connectionTimeout on " + + "the server's entry in mcp.json.", ); const { exitCode, envelope } = classifyError(err); expect(exitCode).toBe(EXIT_CODES.UNREACHABLE); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 83da27d129..63b69127b6 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import * as net from "node:net"; import { createEchoTool, createTestServerHttp, @@ -7,6 +8,7 @@ import { } from "@modelcontextprotocol/inspector-test-server"; import { runCli } from "./helpers/cli-runner.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { MODERN_PROTOCOL_VERSION, type InspectorServerSettings, @@ -214,6 +216,40 @@ describe("CLI --relogin flag conflicts", () => { }); }); +describe("connect-timeout remedy (#2615)", () => { + it("names --connect-timeout and mcp.json, not the web's Server Settings", async () => { + // A listener that accepts the TCP connection and never answers, so the + // handshake hangs until the connect-time timeout fires. + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const { port } = server.address() as net.AddressInfo; + try { + const result = await runCli([ + "--server-url", + `http://127.0.0.1:${port}/mcp`, + "--transport", + "http", + "--connect-timeout", + "200", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toContain("Connection timed out after 200 ms."); + expect(result.stderr).toContain(CLI_CONNECTION_TIMEOUT_REMEDY); + expect(result.stderr).not.toContain("Server Settings"); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + describe("CLI --stored-auth-only wiring", () => { it("accepts the flag and fails connect without interactive OAuth prompts", async () => { // Unreachable host: proves the flag is accepted through parse → callMethod diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 7c3f41f45a..9e33f787d1 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -15,6 +15,7 @@ import type { } from "@inspector/core/mcp/types.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { + CLI_CONNECTION_TIMEOUT_REMEDY, DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout, } from "@inspector/core/cli/handlers/connect-timeout.js"; @@ -209,6 +210,9 @@ async function callMethod( // which nothing here subscribes to, so the lines are discarded without the // child ever blocking on a full pipe (#2435). pipeStderr: args.quiet === true, + // Name the CLI's own knobs on a connect timeout, not the web's Server + // Settings (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, progress: false, sample: false, elicit: false, diff --git a/clients/mcpdo/__tests__/daemon-coverage.test.ts b/clients/mcpdo/__tests__/daemon-coverage.test.ts index 6517986567..9cadc8210d 100644 --- a/clients/mcpdo/__tests__/daemon-coverage.test.ts +++ b/clients/mcpdo/__tests__/daemon-coverage.test.ts @@ -1268,6 +1268,41 @@ describe("mcp connection coverage", () => { expectCliFailure(result); }); + it("names --connect-timeout, not Server Settings, when connect times out (#2615)", async () => { + // Accept the TCP connection and never answer, so the handshake hangs + // until the connect-time timeout fires. + const sockets = new Set(); + const listener = net.createServer((socket) => { + sockets.add(socket); + }); + await new Promise((resolve) => + listener.listen(0, "127.0.0.1", resolve), + ); + const { port } = listener.address() as net.AddressInfo; + try { + const result = await runMcp( + [ + "connect", + `http://127.0.0.1:${port}/mcp`, + "--connection", + "hang", + "--connect-timeout", + "200", + "--format", + "json", + ], + { env: env(), timeout: 10000 }, + ); + expectCliFailure(result); + expect(result.stderr).toContain("Connection timed out after 200 ms."); + expect(result.stderr).toContain("--connect-timeout "); + expect(result.stderr).not.toContain("Server Settings"); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => listener.close(() => resolve())); + } + }); + it("requires explicit connection in non-interactive mode without opt-in", async () => { configPath = createSampleTestConfig(); storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-")); diff --git a/clients/mcpdo/src/connection/authorize.ts b/clients/mcpdo/src/connection/authorize.ts index 2854cfc4d5..a999212b76 100644 --- a/clients/mcpdo/src/connection/authorize.ts +++ b/clients/mcpdo/src/connection/authorize.ts @@ -22,6 +22,7 @@ import { type MCPServerConfig, } from "@inspector/core/mcp/types.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js"; import { connectInspectorWithOAuth } from "@inspector/core/cli/cliOAuth.js"; import { @@ -109,6 +110,8 @@ export async function authorizeInFrontend( name: "inspector-cli", version: readInspectorVersion(import.meta.url), }, + // Same knobs as the daemon's connect: --connect-timeout or mcp.json (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, initialLoggingLevel: "debug", progress: false, sample: false, diff --git a/clients/mcpdo/src/daemon/connections.ts b/clients/mcpdo/src/daemon/connections.ts index 0faee6f9a1..77809afc7e 100644 --- a/clients/mcpdo/src/daemon/connections.ts +++ b/clients/mcpdo/src/daemon/connections.ts @@ -35,6 +35,7 @@ import { loadRunnerClientConfig, } from "@inspector/core/client/runner.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { AuthRecoveryRequiredError, @@ -857,6 +858,9 @@ async function createConnectionClient( name: CONNECTION_CLIENT_NAME, version: readInspectorVersion(import.meta.url), }, + // `mcpdo connect` takes the same --connect-timeout as the one-shot CLI, so + // a timeout names that rather than the web's Server Settings (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, initialLoggingLevel: "debug", progress: false, sample: false, diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index befee2605d..744cfe0a93 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -135,8 +135,13 @@ const h = vi.hoisted(() => { // The options the mount effect built for this server. Captured so a test // can assert what was actually handed to the client — `defaultMetadata` // and the `environment.fetch` proxy wiring (#2067), both of which are - // invisible to a render-only assertion. - opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }; + // invisible to a render-only assertion — plus the connect-timeout remedy + // (#2615). + opts?: { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + }; }> = []; // What the mocked `createProxyFetch` returns. `undefined` is the real // behavior with no proxy env var set; a test points this at a sentinel to @@ -167,11 +172,19 @@ const h = vi.hoisted(() => { class FakeClient { cfg: { type?: string; url?: string } | undefined; opts: - | { defaultMetadata?: unknown; environment?: { fetch?: unknown } } + | { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + } | undefined; constructor( config?: { type?: string; url?: string }, - opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }, + opts?: { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + }, ) { this.cfg = config; this.opts = opts; @@ -390,7 +403,7 @@ vi.mock("../src/utils/openUrl.js", () => ({ openUrl: h.openUrl, })); -import App from "../src/App.js"; +import App, { TUI_CONNECTION_TIMEOUT_REMEDY } from "../src/App.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import type { TuiServer } from "../src/tui-servers.js"; import { @@ -1452,6 +1465,17 @@ describe("App (input handling, focus, effects)", () => { }); }); + it("names mcp.json's connectionTimeout, not Server Settings, as the timeout remedy (#2615)", async () => { + const r = await mount(httpWithSettings()); + await expectFrame(r, "MCP Servers"); + const web = h.clientInstances.find((c) => c.cfg?.url === "http://x"); + expect(web?.opts?.connectionTimeoutRemedy).toBe( + TUI_CONNECTION_TIMEOUT_REMEDY, + ); + expect(TUI_CONNECTION_TIMEOUT_REMEDY).toContain("connectionTimeout"); + expect(TUI_CONNECTION_TIMEOUT_REMEDY).not.toContain("Server Settings"); + }); + it("passes top-level oauth client credentials into an http client", async () => { const r = render( 0 && { timeout: savedSettings.requestTimeout, diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 728cd7a144..c46e6bb4b3 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -477,6 +477,37 @@ describe("InspectorClient", () => { ); }); + it("appends the client-supplied remedy instead of the web's Server Settings wording (#2615)", async () => { + const fakeFactory = () => ({ transport: hangingTransport() }); + const remedy = "To accommodate a slower server, pass --connect-timeout."; + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:1/never" }, + { + environment: { transport: fakeFactory }, + connectionTimeoutRemedy: remedy, + serverSettings: { + headers: [], + env: [], + metadata: {}, + connectionTimeout: 50, + requestTimeout: 0, + taskTtl: 0, + maxFetchRequests: 1000, + roots: [], + }, + }, + ); + const error = await client.connect().then( + () => undefined, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + connectionTimeoutMessage(50, remedy), + ); + expect((error as Error).message).not.toContain("Server Settings"); + }); + it("bounds connect() with DEFAULT_CONNECTION_TIMEOUT_MS when the settings carry no timeout (#2320)", async () => { // No serverSettings at all — the shape every consumer that never opened // Server Settings hands the client. Before #2320 that meant "no bound", diff --git a/core/cli/handlers/connect-timeout.ts b/core/cli/handlers/connect-timeout.ts index 90d81e0115..133767069f 100644 --- a/core/cli/handlers/connect-timeout.ts +++ b/core/cli/handlers/connect-timeout.ts @@ -10,6 +10,14 @@ import { */ export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; +/** + * The remedy the one-shot CLI and mcpdo append to a connect-time timeout error + * (#2615): both accept `--connect-timeout`, and both read `connectionTimeout` + * from the server's `mcp.json` entry — neither has the web's Server Settings. + */ +export const CLI_CONNECTION_TIMEOUT_REMEDY = + "To accommodate a slower server, pass a larger --connect-timeout , or set connectionTimeout on the server's entry in mcp.json."; + /** * Apply a connection timeout to a resolved server's settings, building a * minimal {@link InspectorServerSettings} when none came from the file. diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 3ecabfeade..ea9db7a2e5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -309,18 +309,28 @@ interface TrackedNotificationStream extends NotificationStreamState { */ const MAX_URL_ELICITATION_RETRIES = 5; +/** + * The web client's remedy for a connect-time timeout — the default + * {@link InspectorClientOptions.connectionTimeoutRemedy}, since the web is the + * only client with a Server Settings form to point at. + */ +export const WEB_CONNECTION_TIMEOUT_REMEDY = + "To accommodate a slower server, you may increase the timeout value in Server Settings."; + /** * The message `connect()` rejects with when the connect-time timeout fires. * Names the bound that fired and, since the default is now a real value that * a slow server on a slow link can legitimately hit, says where to raise it - * (#2320). All three clients surface this text as-is: the web client's - * "Failed to connect" toast, the TUI status line, and the CLI error envelope. + * (#2320). Every client surfaces this text as-is — the web client's "Failed + * to connect" toast, the TUI status line, the CLI and mcpdo error envelopes — + * so the remedy sentence is the client's to supply: each one names its own + * knob rather than a web-only setting (#2615). */ -export function connectionTimeoutMessage(timeoutMs: number): string { - return ( - `Connection timed out after ${timeoutMs} ms. To accommodate a slower ` + - `server, you may increase the timeout value in Server Settings.` - ); +export function connectionTimeoutMessage( + timeoutMs: number, + remedy: string = WEB_CONNECTION_TIMEOUT_REMEDY, +): string { + return `Connection timed out after ${timeoutMs} ms. ${remedy}`; } /** @@ -544,6 +554,7 @@ export class InspectorClient extends InspectorClientEventTarget { /** Dedupes concurrent ambient auth challenges (reason + scopes). */ private ambientAuthChallengeInFlight = new Map>(); private pipeStderr: boolean; + private connectionTimeoutRemedy: string; private initialLoggingLevel?: LoggingLevel; // Modern-era per-request log level (#1629). On 2026-07-28 servers // `logging/setLevel` is gone; the client opts into logs per request via the @@ -794,6 +805,8 @@ export class InspectorClient extends InspectorClientEventTarget { // Initialize content cache this.pipeStderr = options.pipeStderr ?? false; + this.connectionTimeoutRemedy = + options.connectionTimeoutRemedy ?? WEB_CONNECTION_TIMEOUT_REMEDY; this.initialLoggingLevel = options.initialLoggingLevel; this.sample = options.sample ?? true; this.elicit = options.elicit ?? true; @@ -1976,7 +1989,14 @@ export class InspectorClient extends InspectorClientEventTarget { const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { connectTimedOut = true; - reject(new Error(connectionTimeoutMessage(connectTimeoutMs))); + reject( + new Error( + connectionTimeoutMessage( + connectTimeoutMs, + this.connectionTimeoutRemedy, + ), + ), + ); }, connectTimeoutMs); }); try { diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 944caa4cb7..ab1376e9ba 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -1097,6 +1097,14 @@ export interface CreateTransportOptions { */ pipeStderr?: boolean; + /** + * The sentence `connect()` appends to its connect-time timeout error, naming + * where this client's user raises the timeout (#2615). Defaults to the web + * client's Server Settings wording; the CLI, mcpdo and TUI pass their own, + * since none of them has that form. + */ + connectionTimeoutRemedy?: string; + /** * Optional callback to track HTTP fetch requests (for SSE and streamable-http transports). * Receives entries without category; caller adds category when storing. @@ -1264,6 +1272,14 @@ export interface InspectorClientOptions { */ pipeStderr?: boolean; + /** + * The sentence `connect()` appends to its connect-time timeout error, naming + * where this client's user raises the timeout (#2615). Defaults to the web + * client's Server Settings wording; the CLI, mcpdo and TUI pass their own, + * since none of them has that form. + */ + connectionTimeoutRemedy?: string; + /** * Initial logging level to set after connection, via `logging/setLevel`. * If not provided, the logging level will not be set automatically. From 56b8104abe3665f933254a4c14d2c2e793510a7d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:35:47 -0400 Subject: [PATCH 47/73] fix(auth): exclude event-stream Accept from discovery detection (#2562) The SDK appends text/event-stream to a user-configured Accept, so a streamable-HTTP GET can carry application/json too. Parse media types and exclude any request asking for an event stream. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 20 +++++++++++++++++++ core/auth/endpointOverrides.ts | 13 +++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 95f2d346e2..1565643a03 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -577,9 +577,29 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => }), ), ).resolves.toBe(original); + // A configured `Accept: application/json` with the SDK's event-stream + // appended is still a stream request. + await expect( + wrapped(FINAL_CANDIDATE, { + headers: { Accept: "application/json, text/event-stream" }, + }), + ).resolves.toBe(original); await expect(wrapped(FINAL_CANDIDATE)).resolves.toBe(original); }); + it("accepts a JSON Accept carrying parameters", async () => { + silenceWarn(); + const wrapped = withOAuthEndpointOverrides( + passThrough(new Response("nope", { status: 404 })), + () => OVERRIDES, + ); + await expect( + wrapped(FINAL_CANDIDATE, { + headers: { Accept: "Application/JSON; charset=utf-8" }, + }), + ).resolves.toHaveProperty("status", 200); + }); + it("does nothing when no override is configured", async () => { const original = new Response("nope", { status: 404 }); const wrapped = withOAuthEndpointOverrides( diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 23e6519d89..6eda332d3c 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -421,7 +421,18 @@ function acceptsJson( } else if (typeof input !== "string" && !(input instanceof URL)) { accept = input.headers.get("accept"); } - return accept?.toLowerCase().includes("application/json") ?? false; + if (!accept) return false; + // Compared per media type, and an event-stream request is excluded outright: + // the SDK appends `text/event-stream` to a user-configured `Accept`, so a + // streamable-HTTP GET can carry `application/json, text/event-stream` and + // must still keep its 401 (Copilot). + const mediaTypes = accept + .split(",") + .map((part) => part.split(";")[0].trim().toLowerCase()); + return ( + mediaTypes.includes("application/json") && + !mediaTypes.includes("text/event-stream") + ); } /** From fe0ddcbe76043d3b0d0a1f9f58cecf709db39992 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:48:26 -0400 Subject: [PATCH 48/73] fix(core): keep connectionTimeoutRemedy off CreateTransportOptions (#2615) The field was added to the wrong interface alongside InspectorClientOptions; transport factories never receive it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- core/mcp/types.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/core/mcp/types.ts b/core/mcp/types.ts index ab1376e9ba..bdd3aa086b 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -1097,14 +1097,6 @@ export interface CreateTransportOptions { */ pipeStderr?: boolean; - /** - * The sentence `connect()` appends to its connect-time timeout error, naming - * where this client's user raises the timeout (#2615). Defaults to the web - * client's Server Settings wording; the CLI, mcpdo and TUI pass their own, - * since none of them has that form. - */ - connectionTimeoutRemedy?: string; - /** * Optional callback to track HTTP fetch requests (for SSE and streamable-http transports). * Receives entries without category; caller adds category when storing. From b29eb6c68e0597e43a74a7b9c31c8406c2a73bdc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:49:07 -0400 Subject: [PATCH 49/73] fix(auth): mirror the SDK's full discovery-continue predicate (#2562) The SDK walks past every non-OK status below 500 (including a non-followed redirect or opaque status 0) plus 502, not just 4xx. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../test/core/auth/endpointOverrides.test.ts | 18 ++++++++++++++++++ core/auth/endpointOverrides.ts | 12 +++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 1565643a03..5c78d081a3 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -515,6 +515,24 @@ describe("withOAuthEndpointOverrides with no published metadata (#2562)", () => ).resolves.toHaveProperty("status", 200); }); + // The SDK walks past every non-OK status below 500, so a redirect a custom + // fetch did not follow is as much "no metadata here" as a 404 (Copilot). + it("treats a non-followed redirect like a 404, as the SDK does", async () => { + silenceWarn(); + const wrapped = withOAuthEndpointOverrides( + passThrough( + new Response(null, { + status: 302, + headers: { location: "https://elsewhere.example.com/" }, + }), + ), + () => OVERRIDES, + ); + await expect( + wrapped(FINAL_CANDIDATE, DISCOVERY_INIT), + ).resolves.toHaveProperty("status", 200); + }); + it("leaves a 500 alone — the SDK surfaces it as an outage", async () => { const original = new Response("boom", { status: 500 }); const wrapped = withOAuthEndpointOverrides( diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 6eda332d3c..2f542d2788 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -305,13 +305,15 @@ const OIDC_WELL_KNOWN = "/.well-known/openid-configuration"; const RFC8414_WELL_KNOWN = "/.well-known/oauth-authorization-server"; /** - * Whether a status makes the SDK move on to the next discovery candidate — - * a 4xx or a 502, mirroring `discoverAuthorizationServerMetadata`. Anything - * else the SDK throws on, and a synthesized document must not paper over an - * outage the SDK would have surfaced. + * Whether a non-OK status makes the SDK move on to the next discovery + * candidate, mirroring `discoverAuthorizationServerMetadata` exactly: anything + * below 500 — a 4xx, but also a redirect a custom fetch did not follow, or an + * opaque status 0 — and 502 (Copilot). Any other 5xx the SDK throws on, and a + * synthesized document must not paper over an outage it would have surfaced. + * Only called for a response that is already not `ok`. */ function continuesDiscovery(status: number): boolean { - return (status >= 400 && status < 500) || status === 502; + return status < 500 || status === 502; } /** From 4405fd03e2544b2dc441084442ee7f499aa6369e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 15:58:34 -0400 Subject: [PATCH 50/73] fix(cli,mcpdo,tui): name no config filename in the timeout remedy (#2615) The TUI and CLI read server entries from --catalog or --config files at any path, and the TUI can start ad hoc, so 'mcp.json' was not always the file to edit. Also assert the mcpdo front-end authorize client carries the CLI remedy. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/cli/__tests__/error-handler.test.ts | 2 +- clients/mcpdo/__tests__/authorize.test.ts | 15 +++++++++++++++ clients/tui/src/App.tsx | 6 ++++-- core/cli/handlers/connect-timeout.ts | 5 +++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index 6997eaab48..a587ac14bc 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -207,7 +207,7 @@ describe("classifyError", () => { const err = new Error( "Connection timed out after 30000 ms. To accommodate a slower server, " + "pass a larger --connect-timeout , or set connectionTimeout on " + - "the server's entry in mcp.json.", + "the server's catalog or config entry.", ); const { exitCode, envelope } = classifyError(err); expect(exitCode).toBe(EXIT_CODES.UNREACHABLE); diff --git a/clients/mcpdo/__tests__/authorize.test.ts b/clients/mcpdo/__tests__/authorize.test.ts index f094e936c3..89b92d15cb 100644 --- a/clients/mcpdo/__tests__/authorize.test.ts +++ b/clients/mcpdo/__tests__/authorize.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; const connectSpy = vi.fn(); const disconnectSpy = vi.fn().mockResolvedValue(undefined); @@ -72,6 +73,20 @@ describe("authorizeInFrontend", () => { expect(options.environment.fetch).toBe(proxyFetch); }); + it("names the CLI's timeout knobs, not the web's Server Settings (#2615)", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = clientCtorSpy.mock.calls[0]?.[1] as { + connectionTimeoutRemedy?: string; + }; + expect(options.connectionTimeoutRemedy).toBe(CLI_CONNECTION_TIMEOUT_REMEDY); + }); + it("no-ops for non-OAuth-capable (stdio) configs", async () => { const { authorizeInFrontend } = await import("../src/connection/authorize.js"); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 07a814dc89..bfd4c4b61f 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -124,10 +124,12 @@ const TUI_CLIENT_NAME = "inspector-tui"; /** * The TUI has no settings form and no per-run timeout flag, so a connect-time * timeout points at the one knob it does read: the server entry's - * `connectionTimeout` in the config file (#2615). + * `connectionTimeout`. That entry may live in a `--catalog` or a `--config` + * file at any path, or not exist yet for an ad-hoc target, so the wording + * names neither a filename nor a single flag (#2615). */ export const TUI_CONNECTION_TIMEOUT_REMEDY = - "To accommodate a slower server, set connectionTimeout on the server's entry in mcp.json."; + "To accommodate a slower server, set connectionTimeout on the server's entry in a catalog (--catalog) or config (--config) file."; /** * Roots snapshot for `useStoreSnapshot`: module scope so both the reader and diff --git a/core/cli/handlers/connect-timeout.ts b/core/cli/handlers/connect-timeout.ts index 133767069f..2def5d05c9 100644 --- a/core/cli/handlers/connect-timeout.ts +++ b/core/cli/handlers/connect-timeout.ts @@ -13,10 +13,11 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; /** * The remedy the one-shot CLI and mcpdo append to a connect-time timeout error * (#2615): both accept `--connect-timeout`, and both read `connectionTimeout` - * from the server's `mcp.json` entry — neither has the web's Server Settings. + * from the server's catalog or config entry — neither has the web's Server + * Settings. The entry's file can be at any path, so no filename is named. */ export const CLI_CONNECTION_TIMEOUT_REMEDY = - "To accommodate a slower server, pass a larger --connect-timeout , or set connectionTimeout on the server's entry in mcp.json."; + "To accommodate a slower server, pass a larger --connect-timeout , or set connectionTimeout on the server's catalog or config entry."; /** * Apply a connection timeout to a resolved server's settings, building a From 840a181a1e97534ca2122be03c1a0be11436751d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 16:42:46 -0400 Subject: [PATCH 51/73] fix(tui): show the Inspector version in the header (#2656) The header box was one row tall and also drew a bottom border, which took that only row and left the title zero height, so Ink clipped the right-justified version. Size it to two rows. On a terminal too narrow for the full title, truncate the description rather than letting it wrap into the border row and squeeze the version out. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/App.test.tsx | 37 ++++++++++++++++++++++++++++++ clients/tui/src/App.tsx | 24 ++++++++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 744cfe0a93..07f2ad9dbf 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render } from "./helpers/renderTui"; import { BodyLines } from "../src/components/BodyLines.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; type RenderResult = ReturnType; @@ -832,6 +833,42 @@ describe("App (foundation)", () => { expect(frame).toContain("beta"); }); + // The header box draws a bottom border, which takes a row of its own; sized + // to one row, the border left the title row zero height and Ink clipped the + // right-justified version off screen (#2656). + describe("header", () => { + const version = readInspectorVersion(import.meta.url); + const originalColumns = process.stdout.columns; + afterEach(() => { + process.stdout.columns = originalColumns; + }); + + async function headerRows(columns: number) { + process.stdout.columns = columns; + const r = renderApp(stdioServer()); + await expectFrame(r, "MCP Servers"); + const [titleRow, borderRow] = (r.lastFrame() ?? "").split("\n"); + return { titleRow, borderRow }; + } + + it("shows the full title and the version where the width allows", async () => { + const { titleRow, borderRow } = await headerRows(100); + expect(titleRow).toContain( + "MCP Inspector TUI - Terminal User Interface for the Model Context Protocol Inspector", + ); + expect(titleRow.trimEnd().endsWith(`v${version}`)).toBe(true); + expect(borderRow).toMatch(/^─+$/); + }); + + it("truncates the description, not the version, on a narrow terminal", async () => { + const { titleRow, borderRow } = await headerRows(80); + expect(titleRow).toContain("MCP Inspector TUI - "); + expect(titleRow).toContain("…"); + expect(titleRow.trimEnd().endsWith(`v${version}`)).toBe(true); + expect(borderRow).toMatch(/^─+$/); + }); + }); + it("auto-selects the first server and shows its config", async () => { const r = renderApp(stdioServer()); await expectFrame(r, "Server Configuration"); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index bfd4c4b61f..43c1e740ed 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -1823,7 +1823,10 @@ function App({ }); // Calculate layout dimensions - const headerHeight = 1; + // Two rows: the title line plus the header's bottom border, which Ink sizes + // inside the box. At 1 the border took the only row, leaving the title zero + // height, so Ink clipped the version off the right edge (#2656). + const headerHeight = 2; const serverListWidth = Math.floor(dimensions.width * 0.3); const contentWidth = dimensions.width - serverListWidth; // Derived, not assumed. The bar wraps once the visible tabs exceed the @@ -1901,13 +1904,22 @@ function App({ justifyContent="space-between" alignItems="center" > - - - {APP_NAME} + {/* On a narrow terminal only the description gives way: wrapped, it + spilled into the border row and squeezed the version out. */} + + + + {APP_NAME} + + + + {" "} + - {APP_DESCRIPTION} - - {APP_DESCRIPTION} - v{APP_VERSION} + + v{APP_VERSION} + {/* Main content area */} From 22b761086b093bf8d4e5870280986de0bf3f5c13 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 16:45:00 -0400 Subject: [PATCH 52/73] feat(web): show tool/prompt/resource counts in list headers and Connection Info (#2607) The Tools and Prompts list headers now read "Tools (N)" / "Prompts (N)", and Connection Info's Server Capabilities column shows the same count beside Tools, Prompts and Resources. Server authors need this to check clients that cap tools per server; until now the only way was the CLI piped through jq. The count is the server's loaded total, not the search-filtered view. In paginated mode with a nextCursor still unloaded it renders as a lower bound ("10+") so a partial count never reads as the total, and it is omitted after a failed load, where "(0)" would read as an empty server. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/web/src/App.tsx | 6 +++ .../CapabilityItem/CapabilityItem.stories.tsx | 10 ++++ .../CapabilityItem/CapabilityItem.test.tsx | 7 +++ .../CapabilityItem/CapabilityItem.tsx | 13 ++++- .../ConnectionInfoContent.test.tsx | 40 +++++++++++++++ .../ConnectionInfoContent.tsx | 50 ++++++++++++++++--- .../ConnectionInfoModal.test.tsx | 15 ++++++ .../ConnectionInfoModal.tsx | 5 ++ .../PromptControls/PromptControls.test.tsx | 29 ++++++++++- .../groups/PromptControls/PromptControls.tsx | 12 ++++- .../groups/ToolControls/ToolControls.test.tsx | 29 ++++++++++- .../groups/ToolControls/ToolControls.tsx | 13 ++++- clients/web/src/utils/listCount.test.ts | 41 +++++++++++++++ clients/web/src/utils/listCount.ts | 39 +++++++++++++++ 14 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 clients/web/src/utils/listCount.test.ts create mode 100644 clients/web/src/utils/listCount.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 5efe762bda..044cef11e3 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -110,6 +110,7 @@ import { import { downloadJsonFile } from "./lib/downloadFile"; import { serverWithDraftSettings } from "./utils/serverWithDraftSettings"; import { offersRunAsTask } from "./utils/toolUtils"; +import { listCountOf } from "./utils/listCount"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink"; @@ -2125,6 +2126,11 @@ function App() { protocolEra={protocolEra} discoverResult={discoverResult} diagnostics={connectionDiagnostics} + listCounts={{ + tools: listCountOf(toolsPagination), + prompts: listCountOf(promptsPagination), + resources: listCountOf(resourcesPagination), + }} oauth={connectionInfoOAuth} onClearOAuth={ connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx index 1c7fb4259d..c0a3c758ec 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx @@ -24,6 +24,16 @@ export const SupportedWithCount: Story = { }, }; +// Paginated mode with a `nextCursor` still unloaded: the count is a lower bound. +export const SupportedWithPartialCount: Story = { + args: { + capability: "tools", + supported: true, + count: 10, + partial: true, + }, +}; + export const NotSupported: Story = { args: { capability: "completions", diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx index ba04dc232a..cd76f0f7c9 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx @@ -24,6 +24,13 @@ describe("CapabilityItem", () => { expect(screen.getByText("Resources (5)")).toBeInTheDocument(); }); + it("marks a partial count as a lower bound", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools (10+)")).toBeInTheDocument(); + }); + it("falls back to capability key for unknown labels", () => { renderWithMantine( , diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx index 34cf5b48e3..e7b8ad3d38 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx @@ -3,13 +3,20 @@ import type { ClientCapabilities, ServerCapabilities, } from "@modelcontextprotocol/client"; +import { formatCountedLabel } from "../../../utils/listCount"; export type CapabilityKey = keyof ServerCapabilities | keyof ClientCapabilities; export interface CapabilityItemProps { capability: CapabilityKey; supported: boolean; + /** Length of the capability's list result (`tools/list`, …) (#2607). */ count?: number; + /** + * True when `count` covers only the pages loaded so far — the server has a + * `nextCursor` still unloaded — so it renders as a lower bound (`10+`). + */ + partial?: boolean; } const displayLabel: Record = { @@ -29,9 +36,13 @@ export function CapabilityItem({ capability, supported, count, + partial = false, }: CapabilityItemProps) { const name = displayLabel[capability] ?? String(capability); - const label = count != null ? `${name} (${count})` : name; + const label = formatCountedLabel( + name, + count != null ? { count, partial } : undefined, + ); return ( diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 00d6b61d9b..954af8ce31 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -293,6 +293,46 @@ describe("ConnectionInfoContent", () => { expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); }); + // #2607: the list-backed capabilities show how many entries the server + // returned, so a server author can check a per-server tool cap at a glance. + it("shows list counts beside the list-backed server capabilities", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tools (15)")).toBe(SUPPORTED_MARK); + expect(capabilityMark("Prompts (10+)")).toBe(SUPPORTED_MARK); + expect(capabilityMark("Resources (0)")).toBe(SUPPORTED_MARK); + // A capability with no list carries no count. + expect(capabilityMark("Logging")).toBe(SUPPORTED_MARK); + }); + + it("shows the bare name when a list has no count, or is unsupported", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tools")).toBe(SUPPORTED_MARK); + // Prompts is unsupported, so its stale count is not shown. + expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK); + expect(screen.queryByText("Prompts (3)")).not.toBeInTheDocument(); + }); + it("renders instructions when present", () => { renderWithMantine( void; } +/** The server capabilities whose support is backed by a list result (`tools/list`, …). */ +export type ListCounts = Partial< + Record<"tools" | "prompts" | "resources", ListCount> +>; + // Label/value pairs in this modal read label-bold, value-normal: the label is // the fixed scaffolding a reader scans down, and the value is the thing that // differs per connection. The reverse (which this was until #2328) bolded every @@ -302,6 +314,20 @@ function getServerCapabilityEntries( }); } +function getListCount( + listCounts: ListCounts | undefined, + capability: CapabilityKey, +): ListCount | undefined { + if ( + capability !== "tools" && + capability !== "prompts" && + capability !== "resources" + ) { + return undefined; + } + return listCounts?.[capability]; +} + export function ConnectionInfoContent({ initializeResult, serverInfoReported = true, @@ -310,6 +336,7 @@ export function ConnectionInfoContent({ protocolEra, discoverResult, diagnostics, + listCounts, oauth, onClearOAuth, }: ConnectionInfoContentProps) { @@ -429,13 +456,22 @@ export function ConnectionInfoContent({ Server Capabilities - {serverCaps.map((cap) => ( - - ))} + {serverCaps.map((cap) => { + // A count only means something beside a ✓ — an unsupported + // capability has no list to count. + const listCount = cap.supported + ? getListCount(listCounts, cap.capability) + : undefined; + return ( + + ); + })} Client Capabilities diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx index 041745f39b..81eb63f3a1 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx @@ -56,6 +56,21 @@ describe("ConnectionInfoModal", () => { expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument(); }); + it("forwards list counts to ConnectionInfoContent (#2607)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools (70)")).toBeInTheDocument(); + }); + it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => { renderWithMantine( void; } @@ -55,6 +58,7 @@ export function ConnectionInfoModal({ protocolEra, discoverResult, diagnostics, + listCounts, oauth, onClearOAuth, }: ConnectionInfoModalProps) { @@ -84,6 +88,7 @@ export function ConnectionInfoModal({ protocolEra={protocolEra} discoverResult={discoverResult} diagnostics={diagnostics} + listCounts={listCounts} oauth={oauth} onClearOAuth={onClearOAuth} /> diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index 3fef265399..2ed659cdfd 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -50,7 +50,7 @@ function ControlledPromptControls(props: Partial) { describe("PromptControls", () => { it("renders the title and search input", () => { renderWithMantine(); - expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByText("Prompts (5)")).toBeInTheDocument(); expect( screen.getByPlaceholderText("Search prompts..."), ).toBeInTheDocument(); @@ -157,10 +157,35 @@ describe("PromptControls", () => { it("renders empty list when no prompts", () => { renderWithMantine(); - expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByText("Prompts (0)")).toBeInTheDocument(); expect(screen.queryByText("translate")).not.toBeInTheDocument(); }); + // The header counts the server's whole list, so a search narrowing the view + // must not change it (#2607). + it("counts every prompt, not just the ones the search matches", () => { + renderWithMantine(); + expect(screen.getByText("Prompts (5)")).toBeInTheDocument(); + }); + + it("marks the count as a lower bound while more pages remain", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Prompts (5+)")).toBeInTheDocument(); + }); + + it("omits the count after a failed load", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.queryByText("Prompts (0)")).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 () => { diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 2fbb5633d6..757aea3957 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -11,6 +11,7 @@ import { } from "../../elements/ListPaginationControls/ListPaginationControls"; import { PromptListItem } from "../PromptListItem/PromptListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; +import { formatCountedLabel } from "../../../utils/listCount"; // Fill the full-height `sidebar` Card (a flex column) so the list runs to the // bottom of the card before it scrolls, instead of being capped short by a @@ -77,10 +78,19 @@ export function PromptControls({ (p.description?.toLowerCase().includes(query) ?? false), ); + // The server's total, not the search-filtered view (#2607); omitted after a + // failed load, where "(0)" would read as an empty server. + const title = formatCountedLabel( + "Prompts", + loadError + ? undefined + : { count: prompts.length, partial: pagination.canLoadMore }, + ); + return ( - Prompts + {title} ) { describe("ToolControls", () => { it("renders the title and search input", () => { renderWithMantine(); - expect(screen.getByText("Tools")).toBeInTheDocument(); + expect(screen.getByText("Tools (4)")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Search tools...")).toBeInTheDocument(); }); @@ -166,7 +166,7 @@ describe("ToolControls", () => { it("renders empty list when no tools provided", () => { renderWithMantine(); - expect(screen.getByText("Tools")).toBeInTheDocument(); + expect(screen.getByText("Tools (0)")).toBeInTheDocument(); expect(screen.queryByText("git_status")).not.toBeInTheDocument(); }); @@ -218,6 +218,31 @@ describe("ToolControls", () => { expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); }); + // The header counts the server's whole list, so a search narrowing the view + // must not change it (#2607). + it("counts every tool, not just the ones the search matches", () => { + renderWithMantine(); + expect(screen.getByText("Tools (4)")).toBeInTheDocument(); + }); + + it("marks the count as a lower bound while more pages remain", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools (4+)")).toBeInTheDocument(); + }); + + it("omits the count after a failed load", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools")).toBeInTheDocument(); + expect(screen.queryByText("Tools (0)")).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 () => { diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 92adc2d57f..513f564353 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -24,6 +24,7 @@ import { import { ToolListItem } from "../ToolListItem/ToolListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; import { toolRowKey } from "../../../utils/toolUtils"; +import { formatCountedLabel } from "../../../utils/listCount"; export interface ToolControlsProps { tools: Tool[]; @@ -167,10 +168,20 @@ export function ToolControls({ })) .filter(({ tool }) => !searchText || matchesQuery(tool, query)); + // The server's total, not the search-filtered view: the header answers "how + // many tools does this server expose" (#2607). Omitted after a failed load, + // where "(0)" would read as an empty server. + const title = formatCountedLabel( + "Tools", + loadError + ? undefined + : { count: tools.length, partial: pagination.canLoadMore }, + ); + return ( - Tools + {title} { + it("renders a complete count as-is", () => { + expect(formatListCount({ count: 15, partial: false })).toBe("15"); + }); + + it("marks a partial count as a lower bound", () => { + expect(formatListCount({ count: 10, partial: true })).toBe("10+"); + }); +}); + +describe("formatCountedLabel", () => { + it("appends the count in parentheses", () => { + expect(formatCountedLabel("Tools", { count: 0, partial: false })).toBe( + "Tools (0)", + ); + expect(formatCountedLabel("Prompts", { count: 3, partial: true })).toBe( + "Prompts (3+)", + ); + }); + + it("returns the bare label when there is no count", () => { + expect(formatCountedLabel("Tools", undefined)).toBe("Tools"); + }); +}); + +describe("listCountOf", () => { + it("counts the loaded items and carries the more-pages flag", () => { + expect( + listCountOf({ items: [1, 2, 3], canLoadMore: true, error: null }), + ).toEqual({ count: 3, partial: true }); + }); + + it("has no count after a failed load", () => { + expect( + listCountOf({ items: [], canLoadMore: false, error: new Error("x") }), + ).toBeUndefined(); + }); +}); diff --git a/clients/web/src/utils/listCount.ts b/clients/web/src/utils/listCount.ts new file mode 100644 index 0000000000..9d54c6fbc9 --- /dev/null +++ b/clients/web/src/utils/listCount.ts @@ -0,0 +1,39 @@ +// How many entries a server list (`tools/list`, `prompts/list`, +// `resources/list`) holds, as the list headers and the Connection Info +// capability rows show it (#2607). Server authors check this against clients +// that cap tools per server, so a partial count must never read as the total: +// in paginated mode with a `nextCursor` still unloaded, the loaded count is +// only a lower bound and is rendered with a trailing `+`. + +export interface ListCount { + /** Entries loaded so far. */ + count: number; + /** True when the server has more pages that have not been loaded yet. */ + partial: boolean; +} + +/** + * The count of a loaded list, or `undefined` after a failed load — where the + * empty `items` would otherwise render as a confident `(0)`. + */ +export function listCountOf(list: { + items: readonly unknown[]; + canLoadMore: boolean; + error: Error | null; +}): ListCount | undefined { + if (list.error) return undefined; + return { count: list.items.length, partial: list.canLoadMore }; +} + +/** `"15"`, or `"15+"` when more pages remain unloaded. */ +export function formatListCount({ count, partial }: ListCount): string { + return partial ? `${count}+` : String(count); +} + +/** `"Tools (15)"` / `"Tools (15+)"`, or just `"Tools"` when there is no count. */ +export function formatCountedLabel( + label: string, + listCount: ListCount | undefined, +): string { + return listCount ? `${label} (${formatListCount(listCount)})` : label; +} From 9203d13e105eb985fa4401cce86000bfeacea533 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 16:48:10 -0400 Subject: [PATCH 53/73] feat(web): form rows for array-of-object tool inputs (#2561) An array whose `items` is an object schema (inline, or a local `$ref` that `toFormSchema` inlines) now renders as a list of rows with add and remove, each row the same indented sub-form a nested object field gets, instead of a bare JSON editor that shows nothing of the item shape. Rows carry stable keys so a removed row's local state does not slide onto its neighbour, a row's invalid draft blocks submission through the outer form, new rows are seeded with the item schema's defaults and constants, and minItems/maxItems gate the buttons. A held value the rows cannot show faithfully keeps the JSON editor. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.stories.tsx | 45 +++ .../groups/SchemaForm/SchemaForm.test.tsx | 289 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 263 ++++++++++++++++ 3 files changed, 597 insertions(+) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx index 345513b76f..5b767e5d1d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx @@ -259,3 +259,48 @@ export const RawJsonToggle: Story = { await expect(canvas.getByLabelText(/^Name$/)).toBeVisible(); }, }; + +/** + * An array whose items are objects, edited as rows (#2561). Each row is the + * item schema's own sub-form, so field names, types, required markers and + * descriptions are all on screen instead of behind a blank JSON editor. + */ +export const ArrayOfObjects: Story = { + args: { + schema: { + type: "object", + properties: { + email: { type: "string", title: "Email" }, + items: { + type: "array", + title: "Items", + description: "The line items to return.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "Product SKU" }, + quantity: { type: "integer", description: "Units to return" }, + }, + required: ["sku", "quantity"], + }, + }, + }, + required: ["email", "items"], + }, + values: { + email: "customer@example.com", + items: [ + { sku: "A-100", quantity: 2 }, + { sku: "B-200", quantity: 1 }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Item 2")).toBeVisible(); + await expect(canvas.getAllByText("Product SKU")).toHaveLength(2); + await expect( + canvas.getByRole("button", { name: "Add item to Items" }), + ).toBeEnabled(); + }, +}; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 45df1ddc6e..9d9822ecac 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -3565,3 +3565,292 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => { expect(noteField().tagName).toBe("INPUT"); }); }); + +describe("SchemaForm array-of-object fields (#2561)", () => { + // The issue's own example, as Rust `schemars` emits it: the item shape lives + // under `$defs` and the array points at it. Through `toFormSchema`, as the + // Tools panel does, so the local `$ref` is inlined first. + const issueSchema = toFormSchema({ + type: "object", + properties: { + email: { type: "string" }, + items: { type: "array", items: { $ref: "#/$defs/Item" } }, + }, + required: ["email", "items"], + $defs: { + Item: { + type: "object", + properties: { + sku: { type: "string", description: "Product SKU" }, + quantity: { type: "integer", description: "Units to return" }, + }, + required: ["sku", "quantity"], + }, + }, + })!; + + function Harness({ + schema, + initial = {}, + onValues, + onValidityChange, + }: { + schema: InspectorFormSchema; + initial?: Record; + onValues?: (values: Record) => void; + onValidityChange?: (invalid: boolean) => void; + }) { + const [values, setValues] = useState>(initial); + return ( + { + setValues(next); + onValues?.(next); + }} + onValidityChange={onValidityChange} + /> + ); + } + + it("renders a row per item with the item schema's labels and descriptions", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /sku/ })).toHaveValue("A1"); + expect(screen.getByRole("textbox", { name: /quantity/ })).toHaveValue("2"); + expect(screen.getByText("Product SKU")).toBeInTheDocument(); + expect(screen.getByText("Units to return")).toBeInTheDocument(); + expect(screen.queryByLabelText("items")).not.toBeInTheDocument(); + }); + + it("shows an empty list with an add button when there are no items", () => { + renderWithMantine( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add item to items" }), + ).toBeEnabled(); + }); + + it("treats an explicit null as an empty list", () => { + renderWithMantine( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + }); + + it("adds, edits and removes items", async () => { + const user = userEvent.setup(); + const onValues = vi.fn(); + renderWithMantine(); + + await user.click(screen.getByRole("button", { name: "Add item to items" })); + expect(onValues).toHaveBeenLastCalledWith({ items: [{}] }); + + await user.type(screen.getByRole("textbox", { name: /sku/ }), "A1"); + await user.type(screen.getByRole("textbox", { name: /quantity/ }), "3"); + expect(onValues).toHaveBeenLastCalledWith({ + items: [{ sku: "A1", quantity: 3 }], + }); + + await user.click(screen.getByRole("button", { name: "Add item to items" })); + expect(onValues).toHaveBeenLastCalledWith({ + items: [{ sku: "A1", quantity: 3 }, {}], + }); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + expect(onValues).toHaveBeenLastCalledWith({ items: [{}] }); + expect(screen.getAllByRole("textbox", { name: /sku/ })[0]).toHaveValue(""); + }); + + it("seeds a new item with the item schema's defaults and constants", async () => { + const user = userEvent.setup(); + const onValues = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + lines: { + type: "array", + items: { + type: "object", + properties: { + kind: { type: "string", const: "line" }, + quantity: { type: "integer", default: 1 }, + }, + required: ["kind"], + }, + }, + }, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Add item to lines" })); + expect(onValues).toHaveBeenLastCalledWith({ + lines: [{ kind: "line", quantity: 1 }], + }); + }); + + it("keeps a row's local state with that row when one above is removed", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + // Enlarging a string field is state the row's own form holds, so it shows + // whether the row that survives is the same mounted row. + await user.click(screen.getAllByRole("textbox", { name: /sku/ })[1]); + await user.keyboard("{Enter}"); + expect(screen.getAllByRole("textbox", { name: /sku/ })[1].tagName).toBe( + "TEXTAREA", + ); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + const remaining = screen.getByRole("textbox", { name: /sku/ }); + expect(remaining.tagName).toBe("TEXTAREA"); + expect(remaining).toHaveValue("second\n"); + }); + + it("honors maxItems and minItems on the add and remove buttons", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + pair: { + type: "array", + minItems: 1, + maxItems: 2, + items: { type: "object", properties: { x: { type: "string" } } }, + }, + }, + }; + renderWithMantine(); + const add = screen.getByRole("button", { name: "Add item to pair" }); + expect( + screen.getByRole("button", { name: "Remove item 1 from pair" }), + ).toBeDisabled(); + + await user.click(add); + expect(add).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove item 1 from pair" }), + ).toBeEnabled(); + }); + + it("disables the add and remove buttons when the form is disabled", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Add item to items" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ).toBeDisabled(); + }); + + it("follows an external change to the array's length", () => { + const { rerender } = renderWithMantine( + , + ); + rerender( + , + ); + expect(screen.getAllByRole("textbox", { name: /sku/ })).toHaveLength(3); + rerender( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + }); + + it("surfaces a row's invalid draft through the outer form, and clears it when the row goes", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByRole("textbox", { name: /quantity/ }), "-"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("renders rows for a nullable item schema", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + rows: { + type: "array", + items: { + anyOf: [ + { type: "object", properties: { name: { type: "string" } } }, + { type: "null" }, + ], + }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /name/ })).toHaveValue("n"); + }); + + it("keeps the JSON editor for a value the rows cannot show", () => { + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Add item to items" }), + ).not.toBeInTheDocument(); + expect(getAceTextByLabel(/items/)).toContain("7"); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index bfaade84c6..06c92caf7b 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -1,8 +1,11 @@ import { + Button, Checkbox, Group, + Input, MultiSelect, NumberInput, + Paper, Select, Stack, Switch, @@ -617,6 +620,233 @@ function SchemaNumberInput({ ); } +// One row of an array-of-objects field (#2561): a bordered card holding the +// item's own sub-form, so where one item ends and the next begins is visible. +const ItemCard = Paper.withProps({ withBorder: true, p: "sm" }); + +// The row's header line — its ordinal on the left, its remove button on the +// right. +const ItemHeader = Group.withProps({ + justify: "space-between", + wrap: "nowrap", +}); + +const ItemTitle = Text.withProps({ size: "xs", fw: 500, c: "dimmed" }); + +const RemoveItemButton = Button.withProps({ + variant: "subtle", + color: "red", + size: "compact-xs", +}); + +const AddItemButton = Button.withProps({ variant: "light", size: "xs" }); + +// Column holding the rows and the add button, below the field's own label. +const ItemList = Stack.withProps({ gap: "xs", mt: "xs" }); + +/** + * Whether a value can be shown as rows of an array-of-objects field: absent, + * `null` (a nullable array with no answer yet), or an array whose every member + * is a plain object. Anything else — a scalar member, a nested array, a value + * that is not an array at all — has no faithful row rendering, so the field + * keeps its JSON editor rather than displaying something other than what would + * be sent. + */ +function isObjectList(value: unknown): boolean { + if (value === undefined || value === null) return true; + return Array.isArray(value) && value.every(isJsonObject); +} + +/** + * Stable React keys for an array-of-objects field's rows, held alongside the + * array rather than derived from the index. Index keys would hand a removed + * row's local state — a half-typed number, an unparsed JSON draft — to the row + * that slides up into its place. + */ +interface RowKeys { + keys: number[]; + next: number; +} + +/** Grow or shrink the row keys to match `length`, keeping the ones that stay. */ +function fitRowKeys(state: RowKeys, length: number): RowKeys { + if (state.keys.length === length) return state; + if (state.keys.length > length) { + return { ...state, keys: state.keys.slice(0, length) }; + } + const added = Array.from( + { length: length - state.keys.length }, + (_, offset) => state.next + offset, + ); + return { keys: [...state.keys, ...added], next: state.next + added.length }; +} + +interface ObjectArrayFieldProps { + fieldName: string; + label: string; + description?: string; + withAsterisk: boolean; + disabled: boolean; + /** The field's value with its default resolved; see {@link isObjectList}. */ + value: unknown; + /** The `items` schema, already known to be an object with properties. */ + itemSchema: InspectorFormSchema; + minItems?: number; + maxItems?: number; + /** Prefix for each row's nested form `resetKey`. */ + resetKey?: string; + onChange: (value: Record[]) => void; + onValidityChange: DraftValidityReporter; +} + +/** + * An array whose items are objects, edited as a list of rows (#2561). + * + * Before this, such a field fell through to the JSON editor, which shows + * nothing of the item shape — no field names, types, required markers or + * descriptions — although the schema carries all of them, and list-shaped + * inputs are often a tool's most important argument. Each row is the same + * indented sub-form a nested object field already renders, so every item gets + * those labels for free. + * + * A new row is seeded with the item schema's defaults and constants here, + * rather than left for the row's own form to seed on mount: that seeding + * reports through a closure over the array as it was when the row rendered, + * so it cannot be relied on to compose with an edit to a sibling row. + * + * `minItems`/`maxItems` gate the remove and add buttons, so the list cannot be + * taken outside the length the schema states. + */ +function ObjectArrayField({ + fieldName, + label, + description, + withAsterisk, + disabled, + value, + itemSchema, + minItems, + maxItems, + resetKey, + onChange, + onValidityChange, +}: ObjectArrayFieldProps) { + const items = Array.isArray(value) + ? (value as Record[]) + : []; + + const [rowKeys, setRowKeys] = useState(() => + fitRowKeys({ keys: [], next: 0 }, items.length), + ); + // An external change of length — a cleared form, a loaded example, the raw + // JSON editor — re-fits the keys. The add and remove handlers fit them + // themselves, so for those this finds nothing to do. + useValueChange(items.length, (length) => + setRowKeys((previous) => fitRowKeys(previous, length)), + ); + + // Which rows hold a draft that cannot be sent. Keyed by row key, not index, + // so removing a row above an invalid one cannot move the block onto the + // wrong row. + const [invalidRows, setInvalidRows] = useState>( + () => new Set(), + ); + const reportRowValidity = useCallback((rowKey: number, invalid: boolean) => { + setInvalidRows((previous) => { + if (previous.has(rowKey) === invalid) return previous; + const next = new Set(previous); + if (invalid) next.add(rowKey); + else next.delete(rowKey); + return next; + }); + }, []); + useDraftValidity(fieldName, invalidRows.size === 0, onValidityChange); + + const canAdd = maxItems === undefined || items.length < maxItems; + const canRemove = minItems === undefined || items.length > minItems; + + function addItem() { + setRowKeys((previous) => fitRowKeys(previous, items.length + 1)); + onChange([ + ...items, + applySchemaConstants(itemSchema, collectSchemaDefaults(itemSchema)), + ]); + } + + function removeItem(index: number) { + setRowKeys((previous) => ({ + ...previous, + keys: previous.keys.filter((_, position) => position !== index), + })); + onChange(items.filter((_, position) => position !== index)); + } + + function updateItem(index: number, item: Record) { + onChange( + items.map((existing, position) => (position === index ? item : existing)), + ); + } + + return ( + // A wrapper rather than a bare label: it gives the field the same label, + // required marker and description every other field has. Rendered as a + // `div` because there is no single control for a `