diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index 1b3379fe..fc151b5e 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -30,10 +30,19 @@ review, which is the argument for not making a fourth. `npm run local:gate` prints each stage as it starts, so the running command is the other reliable answer. -It is a **strict superset** of GitHub CI (which additionally runs `npm install`, -and runs `coverage` as a parallel job). So the direction that matters holds: -**passing `local:gate` locally means CI's gates will pass.** The reverse does -not. +It runs **every check** GitHub CI runs (which additionally runs `npm install`, +and runs `coverage` as a parallel job), plus two local-only steps. So the +direction that matters holds: **passing `local:gate` locally means every check +CI applies has already passed on your machine** — the strongest predictor of a +green CI there is here, though not a proof (a different OS, and the bare test +pass noted below). The reverse does not hold at all. + +One difference in *invocations*, not checks: CI runs each client's unit suite +twice — bare inside `validate`, instrumented inside `coverage` — on two +parallel runners, while the gate runs it **once**, instrumented (#2341). The +gate's first stage is `local:validate`, which is `validate` minus each client's +`test` leg; `npm run validate` itself is unchanged. The reasoning is in +[`AGENTS.md`](../../../AGENTS.md#mandatory-pre-push-gate). ⚠️ **There is no `npm run ci`.** The gate was renamed to `local:gate` (#2146) precisely because `npm ci` is a built-in that clean-installs from the lockfile @@ -87,6 +96,22 @@ versions. **Align the versions** — bump it in every install that declares it. Do not raise the heap with `--max-old-space-size`; that hides the class rather than fixing it. +### `verify:test-timeouts` + +A Vitest project resolves to a wall-clock budget nobody stated, or stopped +loading `vitest.setup.shared.mts`. (A `retry` itself fails at **runtime**, from +that setup file, with a message naming the test — not here.) The shared values live in `vitest.shared.mts` (`TIMEOUTS` / +`INTEGRATION_TIMEOUTS`) and every project spreads one of them — so **raise a +budget there**, not with a per-suite `}, 30_000)` argument, which only moves the +one site and leaves every future file on the default. A per-suite raise is +right only where the work is genuinely different (real cross-process lock +contention, a full OAuth round trip); say so at the site. `retry` stays unset: +it turns a load-induced red into a silent green on the only pre-push gate here. + +A **failing test** is a different problem from a budget — read the failure +before reaching for a number. An assertion that races is #1596's class and is +fixed with fake timers or an awaited condition, not with headroom. + ### `lint` **There is no warning tier** — every `lint` script runs `--max-warnings 0`, so a @@ -123,9 +148,44 @@ Vite's `fs.allow`. Do a real `npm install` in the worktree. ### Everything times out at once -⚠️ Two concurrent `npm run local:gate` runs starve each other — ~326 tests time -out at 5s. Run one at a time. (A `pgrep -f "npm run local:gate"` wait loop -matches _itself_ and never exits.) +⚠️ Two `npm run local:gate` runs on one machine starve each other — under four +worktrees ~326 tests timed out at 5s (#2323), and even two collide +deterministically on the web smokes' fixed ports. Since #2339 the gate takes a +**machine-wide lease**, so a second run queues rather than overlapping; if +everything is still timing out at once, look for what is _not_ the gate: a +bypassed lease (`INSPECTOR_SKIP_GATE_LEASE` set in that shell), a bare +`npm run coverage` or `test:storybook` in another session, or Spotlight +indexing a fresh `node_modules` (a `mdworker` storm after `npm install` in a +new worktree pushed the load average to 20 for ten minutes). Do not write a +"wait until the machine is clear" loop — two of them deadlock on each other, +and a `pgrep -f "npm run local:gate"` loop matches _itself_ and never exits. + +### Waiting on the lease + +A gate that starts with + +``` +gate-lease: pid 12345 in /Users/you/Projects/mcp-inspector-wt-1, running for 2m10s holds the gate lease; waiting … +``` + +is queued behind another worktree's gate, and will start the moment it +releases (it re-checks every 2s and prints `still waiting` once a minute). The +holder's pid and worktree are in the line, so you can decide whether to wait +or to stop that gate. A holder that was **killed** — a closed terminal, an +OOM'd session — stops refreshing its lock and is taken over after 30s; nothing +needs cleaning up by hand. The one exception is a dead holder's lock directory +that cannot be removed (a stray file inside it, or permissions): the takeover +fails, the waiter keeps waiting, and the wait runs to its 45-minute cap naming +the path — remove that directory by hand. The cap is a total wait budget, counted +from the waiter's first attempt and not reset as the queue ahead of it drains, +so a queue of healthy gates deeper than it covers — about ten, at ~4.5 minutes +each — reaches it too. So the give-up happens against a live gate that has +hung, a stale lock that would not go away, or a queue that deep; never on its +own. + +`INSPECTOR_SKIP_GATE_LEASE=1 npm run local:gate` runs without the lease. It is +for a measurement that needs contention; it does not get a result sooner, +because the queued run finishes before an overlapped one would. ## Local-only steps @@ -147,6 +207,8 @@ it — the split is the design. `npm run pack:verify` builds, packs the real tarball, installs it into a clean throwaway consumer, and drives the installed `mcp-inspector` bin end to end. It -needs **network access**, so it is a local/release check and is in neither -`local:gate` nor CI. Run it when touching packaging (the `files` allowlist, -a bundler `external` list, anything read from disk at runtime). +needs **network access**, so it is a local/release check: it is **not** a +`local:gate` stage, and CI runs it only in the `publish` job on a published +GitHub Release — never on a push or PR. Run it when touching packaging (the +`files` allowlist, a bundler `external` list, anything read from disk at +runtime), and as its own step when cutting a release (the `release` skill). diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index ee526531..d409acb5 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -36,7 +36,7 @@ other. | Branch | `v2/chore/-bump-`, cut from `origin/v2/main` | the milestone-merge branch, cut from `origin/main` | | Base | **`v2/main`** | **`main`** | | Carries | the `npm audit` report, **any fixes the audit forces**, and the **version bump** — all three, one PR | the milestone's work, arriving whole from `v2/main`. **No commits of its own.** | -| Verified by | `npm run local:gate` | `npm run local:gate` **plus** a hand-driven smoke of every contribution in the milestone, from the **production build**, written up as a **ledger artifact** | +| Verified by | `npm run local:gate` | `npm run local:gate`, **plus** `npm run pack:verify` (not a gate stage), **plus** a hand-driven smoke of every contribution in the milestone, from the **production build**, written up as a **ledger artifact** | | Merged when | reviewed and green | the ledger is reviewed by the maintainers and clean | Then, and only then, a maintainer tags and publishes the **GitHub Release** @@ -143,9 +143,16 @@ Then drive it. Work from a **dedicated worktree** with its own full `npm install` (a symlinked `node_modules` passes lint and tests and then fails every story file), run `npm run local:gate` there, and exercise the app from the **production build** — the packaged bin and the built bundles, not `vite dev`. -The `local-dev`, `test-servers` and `pre-push-gate` skills cover the mechanics; -`pack:verify` inside the gate is what proves the tarball a consumer installs -actually resolves. +The `local-dev`, `test-servers` and `pre-push-gate` skills cover the mechanics. + +**Then run `npm run pack:verify` there as its own step.** It is what proves the +tarball a consumer installs actually resolves, and ⚠️ **`local:gate` does not +run it** — `local:gate:stages` has no packaging stage, and a green gate says +nothing about the published tarball (#2380). CI runs it only in the `publish` +job, which fires on the published GitHub Release — after the tag exists — so +skipping it here means the first signal of a broken package arrives too late to +stop the release. It needs network access; record its result (tarball size and +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 @@ -165,11 +172,13 @@ and link it from PR 2. Shape it like the standfirst saying what tree was tested and that its hash matches `origin/v2/main`, plus whether the milestone payload is complete (the only issue left open should be the merge itself). -- **Verdict band** — `local:gate` result, milestone issues verified as `N / N`, - distinct test count, regressions found. -- **The automated gate** — one cell per stage with its number (file counts, - test counts, smoke count, `pack:verify` size), and a note on what is new this +- **Verdict band** — `local:gate` and `pack:verify` results, milestone issues + verified as `N / N`, distinct test count, regressions found. +- **The automated gate** — one cell per `local:gate` stage with its number + (file counts, test counts, smoke count), and a note on what is new this milestone. +- **The packaging check** — `pack:verify` in its own cell, apart from the gate + stages because it is not one of them: its result and the tarball size. - **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. diff --git a/.claude/skills/test-servers/SKILL.md b/.claude/skills/test-servers/SKILL.md index 58da2bd5..5c252e1a 100644 --- a/.claude/skills/test-servers/SKILL.md +++ b/.claude/skills/test-servers/SKILL.md @@ -17,9 +17,171 @@ and what the broken build did — is [`docs/test-servers.md`](../../../docs/test-servers.md). This skill is how to run one. +## Three ways to use a fixture — pick the right one first + +A fixture is stood up in **one of three shapes**, and most of what follows is +about the third. Establish which one you are in before reading further, because +the showcase **config file and the protocol-era table** belong to that one alone. + +⚠️ **The cut is how the server is stood up, not who is driving.** Automated and +by-hand is the wrong axis: the composable-config shape has *both* kinds of +consumer, and a smoke that spawns it needs every bit of the config and era +guidance a person at two terminals does. + +| Shape | Server runs | Config file | Consumers | +| --- | --- | --- | --- | +| **In-process HTTP** | inside the test process, built from the API | none — options are constructor args | integration tests, CLI tests, **`smoke:cli`** | +| **Spawned stdio** | a child process the transport (or the binary under test) starts | none — the stdio fixture runs its default config | integration tests, the CLI suites, **`smoke:cli`**, **`smoke:tui`** | +| **Spawned composable HTTP** | a child process started with `--config` | **yes** — a showcase `--config .json` | the **web** smokes, `pack:verify`, **and** a person by hand | + +⚠️ **"A smoke" is not a shape** — `smoke:cli` uses the first two and `smoke:tui` +the second, while only the config-driven web smokes use the third. Pick by the +row, never by the caller's category. + +- **In-process HTTP — `createTestServerHttp`.** The caller *constructs* the + server and owns its lifecycle. No subprocess, no JSON config, no showcase + config to pick. This is the shape for anything needing HTTP/SSE, a specific + tool set, or the modern handler — and it is not test-only: `scripts/smoke-cli.mjs` + starts one in the smoke process so it can read back the headers the CLI sent. +- **Spawned stdio — `getTestMcpServerCommand()`.** The test hands the built + fixture's `{ command, args }` to a stdio transport (or to the built CLI), and + the transport spawns it. A subprocess *is* started, but **still no config + file**: that entry point runs the stdio server's default config, so there is + nothing to pick. Reach for it when stdio is the point (`InspectorClient` over + stdio, the CLI's out-of-process E2E suite) and the default tool set is enough. + A caller may also just *name* the built entry rather than connect to it — + `smoke:cli` and `smoke:tui` write it into a `--catalog` as + `{ type: "stdio", command: node, args: [] }` — which is still + this shape, and still the build. +- **Spawned composable HTTP — `server-composable.js --config .json`.** + Picking the showcase config and the protocol era applies **to this shape**, + whoever starts it. Two consumers, and they differ only in who runs the second + process: + - **A script.** `scripts/smoke-web-elicitation.mjs` spawns it directly; + `scripts/lib/mcp-app-flow.mjs` (`startMcpAppServer`) does it for + `smoke:web:app`, `smoke:web:tabs` and `pack:verify`. These are automated and + config-driven, and the whole of this skill applies to them. ⚠️ **Not every + smoke is here** — `smoke:cli` and `smoke:tui` use the two shapes above and + pick no config at all. + - **You, in a terminal**, with the Inspector in another — `Run one by hand` + below. + +⚠️ **The build applies to all three.** Every shape resolves +`test-servers/build/` — the two API-driven ones through the +`@modelcontextprotocol/inspector-test-server` alias, the composable one by +running the emitted `.js` directly — so `Build first` and its stale-build hazard +are **not** guidance for one path. Read that section whichever shape you are in. + +### Automated, in-process HTTP: build the server from the API + +```ts +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, +} from "@modelcontextprotocol/inspector-test-server"; + +let server: TestServerHttp | null = null; + +afterEach(async () => { + // Stop it even when the assertion threw, or the port leaks into the next test. + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } +}); + +it("…", async () => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("excluded-tools-test", "1.0.0"), + tools: [createEchoTool()], + // `modern: {}` opts the fixture into the 2026-07-28 handler; omit it for legacy. + }); + await started.start(); + server = started; + + // `started.url` is the bound URL — read it, never reconstruct it from a port. + // …connect an InspectorClient to it and assert. +}); +``` + +The reference test is +[`clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts`](../../../clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts) +— read it before writing a new one; it is the shape fixture-backed integration +tests follow when the server is built in-process. (Stdio-backed ones follow the +next subsection instead.) + +Four mechanics of this path: + +- **The factories come from one barrel.** `createTestServerHttp` / + `createTestServerStdio` build the server; the `create*Tool`, + `create*Resource` and `create*Prompt` fixtures in + `test-servers/src/test-server-fixtures.ts` populate it; + `createTestServerInfo` fills in `serverInfo`. Prefer an existing fixture + factory to hand-writing a `ToolDefinition` — that is what makes the fixture a + shared one. +- **`start()` then `stop()`, and `stop()` in an `afterEach`.** The server binds a + real port, so a test that throws before stopping leaks it into the rest of the + file. +- **Read `started.url`.** `createTestServerHttp` resolves through + `findAvailablePort()`, which walks upward when the port is taken, so an + assumed port is the same bug the two-process path has. +- **Era is a constructor option, not a config file.** `modern: {}` on the config + object selects the modern handler; the client side picks its own negotiation + (`eraToVersionNegotiation`). The showcase-config era table below does not + apply. + +⚠️ **The barrel is an alias to the BUILD, not to the source** — +`vitest.shared.mts` maps `@modelcontextprotocol/inspector-test-server` to +`test-servers/build/index.js`. So `Build first` applies to this path in full, +stale-build hazard included: an edit to `test-servers/src` that is not rebuilt +is invisible to an in-process test exactly as it is to a spawned one. + +### Automated, spawned stdio: hand over the command + +```ts +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; + +const { command, args } = getTestMcpServerCommand(); +const client = new InspectorClient( + { type: "stdio", command, args }, + { environment: { transport: createTransportNode } }, +); +await client.connect(); +// … afterEach → client.disconnect(), which is what stops the child. +``` + +`getTestMcpServerCommand()` returns `node `. +Three consequences: + +- **You do not own the process, the transport does.** There is no `start()` / + `stop()` pair — disconnecting the client is what reaps the child, so the + `afterEach` that matters is `client.disconnect()`. +- **No config is selected and none can be.** That entry point starts the stdio + server on its **default** config, so the showcase-config table and the + protocol-era guidance below do not apply. If the case needs a specific tool + set or the modern handler, it is an in-process HTTP test, not this. +- **It is still the build.** The path comes from the module's own resolved + location under the alias, so it is `test-servers/build/`, with the same + staleness hazard. + +The same command feeds the CLI's out-of-process E2E suite +(`clients/cli/__tests__/e2e.test.ts`), which spawns the built CLI *and* lets it +spawn the fixture. Reference tests for this shape: +`clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts` +and `clients/cli/__tests__/methods.test.ts`. + ## Build first -The servers are spawned as real subprocesses, so the build output must exist: +Every shape above resolves generated output — the in-process one imports the +barrel, which is **aliased to `test-servers/build/index.js`**, and the stdio and +composable-config ones run emitted `.js` as real subprocesses. So the build must +exist whichever one you are in: ```sh cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/ @@ -41,9 +203,11 @@ rm -rf test-servers/build The `.tsbuildinfo` is pinned inside `build/` so that clean actually invalidates the cache. -## Run one +## Run one by hand (two processes) -Two processes: the test server, then the Inspector. +This is the **spawned composable HTTP** shape from the section above, driven by +you rather than by a smoke script — the config and era guidance is the same +either way. Two processes: the test server, then the Inspector. ```sh # 1. The server, from the repo root, with the config you picked: diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 85c8caa5..6da0df69 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -11,7 +11,7 @@ statements, functions, and branches.** That rule and the React/Mantine conventions live in [`AGENTS.md`](../../../AGENTS.md); this skill is where a test goes, how to run it, and how to clear the gate. -## Before you write it: does the test need a real server? +## Before you write it: does the test use a `test-servers/` fixture? **If it does, load the `test-servers` skill now — that is step one, before choosing a location or writing a line.** @@ -23,8 +23,35 @@ ways to depend on one, and they need different halves of that skill: - **It connects to a fixture.** An integration test that connects; an end-to-end test that connects; a smoke that drives a connected flow; a coverage gap only reachable over a real connection; reproducing a reported bug - against a server. These need the whole procedure — which showcase config, - which protocol era, and the staleness hazard. + against a server. These need the whole procedure — the staleness hazard, and + then whichever half matches how the server is stood up. + ⚠️ **Which shape you need depends on what is driving, and there are three.** + All three are the **"Three ways to use a fixture"** section of `test-servers`, + which names the entry point and a reference for each — read the right third: + - **An integration or CLI test → in-process HTTP.** + `createTestServerHttp(...)` / `.start()` / `.stop()`, with the test owning + the lifecycle. Use it when the case needs HTTP or SSE, a specific tool set, + or the modern handler (`modern: {}` is a constructor option). **No showcase + config and no era table apply.** + - **An integration or CLI test where stdio is the point → spawned stdio.** + `getTestMcpServerCommand()` handed to a stdio transport or to the built CLI, + which spawns it. A subprocess *is* started, but it runs the stdio fixture's + **default** config, so there is still nothing to pick — and nothing to + override, so if the case needs a specific tool set it is an in-process HTTP + test instead. + - **A config-driven web smoke, or `pack:verify` → spawned composable HTTP.** + `smoke:web:elicitation`, `smoke:web:app`, `smoke:web:tabs` and `pack:verify` + all spawn `server-composable.js --config .json`. **The showcase-config + and protocol-era guidance applies to you in full** — being automated does + not exempt a smoke from it. + + ⚠️ **"A smoke" is not a shape, so do not route by that word.** `smoke:cli` + uses the first two — an in-process `createTestServerHttp` for the header + round-trip, and the built stdio entry in a `--catalog` for the connect checks + — and `smoke:tui` uses the stdio entry alone. Only the web smokes above are + config-driven. Pick by what the caller actually stands up. + + What applies to all three is that section's build warning. ⚠️ **Connecting is a strong hint, not the rule.** A few integration tests deliberately hand-roll a JSON-RPC server because the composable fixture *cannot* produce what they assert on — `inspectorClient-malformed-list.test.ts` @@ -39,7 +66,19 @@ ways to depend on one, and they need different halves of that skill: ⚠️ **"A build ran" is not the dependency — using the artefact is.** `clients/web`'s `pretest` runs `test-servers:build` before *every* unit run, so the fixture is on disk for tests that never reference it. What counts is whether -the test imports, spawns, or points a config at it. +the test **starts, spawns, configures, or hands a built entry to the subject +under test**. That last clause is what covers `smoke:tui`, which drives no +transport at all and still depends on the fixture — see the build-only bullet +above. + +⚠️ **And *importing* the package is not the dependency either.** The barrel +exports plain functions as well as server factories, so a test can import from +it and never stand a server up — `src/test/core/mcp/test-server-scope.test.ts` +imports `createScopeCheckMiddleware` and friends to unit-test the scope +middleware as a pure function, with no `start()` anywhere in the file. None of +the procedure applies to it — no config, no era, no lifecycle — it is an +ordinary unit test that happens to import its subject from that package. Ask +whether a *server* runs, not whether the import line is present. So the condition does **not** hold when the test renders a component from fixture props, exercises a pure function or a parser, or is a smoke that touches @@ -216,9 +255,16 @@ renders. The rule stands on consistency, not on timer safety. `defaultColorScheme="dark"`. - **Mid-flight transition state** (e.g. asserting a `data-anim="out"` cell during an exit crossfade) is the only reason to use `renderWithMantineTransitions`. - Pass `settleMs` derived from the component's real animation duration - (`HEADER_ANIM_MS + 200`), do **not** also use `vi.useFakeTimers()` in that test - (the auto-settle no-ops under fake timers), and if the test unmounts the tree + Pass `settleMs` derived from the component's real animation duration **plus + the helper's shared slack** — `HEADER_ANIM_MS + RAF_SLACK_MS`, both imported, + never a literal: the first term tracks the component and the second tracks how + busy the machine is, and only the second should move when the machine gets + busier (#2323). Do **not** also use `vi.useFakeTimers()` in that test: + the auto-settle awaits a real `setTimeout`, so under fake timers it **throws** + with a message telling you to call `vi.useRealTimers()` first — it does not + silently skip. That is deliberate (a deadlock would otherwise hang until the + project's `hookTimeout`), but it means the combination fails the test rather + than degrading. If the test unmounts the tree itself use the `unmount()` the helper returns. The mechanism is documented at length on the helper — read there before changing it. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f2f4c0f5..a749c9bf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,9 +17,22 @@ on: permissions: contents: read +# Every job below declares `timeout-minutes` (#2333). It is a HUNG-JOB GUARD, +# not a flake remedy: GitHub runners are not the contended machine the local +# gate runs on, and nothing measured implicates them — so no value here is +# sized for load. The rule, for every job in this file, is roughly TWICE the +# slowest run observed, rounded up to the next five minutes: real headroom for +# a slow runner, and a hang cut off in minutes instead of at GitHub's +# 360-minute default. Each job states the range it was read from; when one +# legitimately outgrows its number, raise it here with the new range. jobs: build: runs-on: ubuntu-latest + # Observed 7.6–11.8 min across the 40 push runs before this landed + # (2026-09-11 → 09-13), all since the coverage split (#2159). A release run + # before that split reached 15.6 min, with the coverage gate still inside + # this job — that shape no longer exists. + timeout-minutes: 25 steps: - name: Checkout code @@ -38,8 +51,9 @@ jobs: run: npm install - name: Validate (coverage guards, format, lint, typecheck, build, fast tests) - # Runs the four durable guards first (verify:format-coverage, - # verify:skills, verify:typecheck-coverage, verify:dep-lockstep), then + # Runs the five durable guards first (verify:format-coverage, + # verify:skills, verify:typecheck-coverage, verify:dep-lockstep, + # verify:test-timeouts), then # test:scripts, then validate:core, then each client's # self-validation: format:check + lint + typecheck + build + test (no # coverage instrumentation — fast). This also builds every client bundle @@ -48,7 +62,9 @@ jobs: # (#2159), which consumes nothing this job produces — every client's # `test:coverage` builds whatever it needs itself. Unit tests run in # both jobs (fast here, instrumented there); now that the two run in - # parallel that duplication costs no wall clock at all. + # parallel that duplication costs no wall clock at all. (The local gate, + # which runs serially, calls `local:validate` instead and runs them once + # — #2341. Keep `validate` here: this job IS the fast inner loop.) # A future optimization could split `coverage` into per-client parallel # jobs, but that's a larger restructure and deliberately out of scope. run: npm run validate @@ -108,12 +124,13 @@ jobs: - name: Run cross-client smokes # NOTE: this workflow runs GitHub CI's tier only. The LOCAL pre-push - # gate is `npm run local:gate`, and it is a strict superset — do not add - # it, a `local:*` script, `smoke:web:firefox`/`smoke:web:webkit`, - # `smoke:web:engine`, or a non-Chromium `SMOKE_BROWSER` to any workflow. + # gate is `npm run local:gate`, and it runs every check here plus the + # local-only ones — do not add it, a `local:*` script, + # `smoke:web:firefox`/`smoke:web:webkit`, `smoke:web:engine`, or a + # non-Chromium `SMOKE_BROWSER` to any workflow. # `npm run test:scripts` fails if you do (scripts/lib/workflow-gate.mjs, # #2146). `npm run smoke` and `smoke:web:chromium` belong here and are - # not affected. The canonical CI-vs-local table is in the root README. + # not affected. The canonical CI-vs-local split is docs/quality-gate.md. # # End-to-end smokes through the built launcher (--help dispatch + prod # CLI/web). Not part of any client's `validate`: it needs the @@ -146,6 +163,8 @@ jobs: # time tests out at the 5s default. Separate jobs get separate runners. coverage: runs-on: ubuntu-latest + # Observed 5.9–8.5 min across the same 40 push runs as `build`. + timeout-minutes: 20 steps: - name: Checkout code @@ -191,6 +210,11 @@ jobs: concurrency: group: publish-npm cancel-in-progress: false + # Observed 2.3–2.8 min across the last three releases (2.4.0–2.6.0), of + # which `pack:verify` is about half and the only step that reaches the + # registry. The publish itself is atomic on npm's side, so a cut-off here + # leaves nothing half-published. + timeout-minutes: 10 permissions: contents: read # Required for npm provenance (`--provenance` mints a signed attestation @@ -279,6 +303,9 @@ jobs: if: github.event_name == 'release' environment: release needs: [build, coverage] + # Observed 14.5–15.4 min across the last three releases (2.4.0–2.6.0); + # the linux/arm64 half of the build runs under QEMU and is most of it. + timeout-minutes: 35 permissions: contents: read packages: write diff --git a/AGENTS.md b/AGENTS.md index a42c9669..d05cc5a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ The reasoning behind each of these, and what breaks when it is ignored, is the - **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. - **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale. - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way). - - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. + - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301). - **A root-declared package that `core/` imports at runtime must also be named in all three bundler `external` lists** (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. - **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`). - **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both. @@ -130,7 +130,7 @@ The board write needs `organization projects: write`, which `GITHUB_TOKEN` canno **`.github/workflows/sdk-watch.yml` → `scripts/sdk-watch.mjs` runs nightly (#1063) and files one issue per MCP SDK release we are behind**, labeled `v2` + `chore` + `dependencies`. It is not a Dependabot replacement — it exists because SDK churn, OAuth especially, was being tracked by habit rather than by mechanism — but it obeys the same rule as the two above: **it files an issue, never a PR.** - **Two upstreams, two issues.** `client`/`core`/`server`/`server-legacy` ship from `modelcontextprotocol/typescript-sdk` in lockstep and share one issue; `ext-apps` ships from its own repo and gets its own. A fifth `@modelcontextprotocol/*` package added to the root manifest and not added to `SDK_GROUPS` **fails the sweep loudly** rather than going unwatched — that guard is the point, since a hardcoded group table is otherwise a silent blind spot. -- **It compares the INSTALLED version, not the declared range.** The four SDK packages are pinned exactly, so the two agree for them; `ext-apps` is a caret range whose lockfile already resolves higher, and comparing the declared string would file an issue for a bump `npm install` has already taken. +- **It compares the INSTALLED version, not the declared range.** The four SDK packages are pinned exactly, so the two agree for them; `ext-apps` is a caret range, so its lockfile can already resolve higher than the manifest's floor, and comparing the declared string would file an issue for a bump `npm install` has already taken. - **The target is the LOWEST `latest` across a group — the version the whole group has reached — not the highest.** npm publishes a lockstep release one package at a time, so a sweep landing mid-publish sees one package ahead of its three siblings. Targeting the highest would name a version three of them do not have _and_ write a marker that suppresses the real filing once the publication completes, so the release would never be tracked at all. Taking the minimum keeps the issue actionable and lets the completed release file its own. - **It never boards, like the monthly sweep** — no `PROJECT_TOKEN` exists in this org — so the issue arrives labeled and milestoned and `/issue-triage` places it. - **It never closes an issue either.** A further release files its own issue and leaves a **supersession comment** on the older one; closing is a maintainer act, since the card may already have moved. An issue closed for the same target keeps suppressing it, so a maintainer's "not planned" is not re-argued nightly. @@ -217,8 +217,8 @@ Skills are conditional — a skill's body loads only when it is invoked — so a skill that stops being reachable loses behavior **silently**. Four rules keep that from happening: -1. **`npm run verify:skills` must pass.** It runs inside `validate` (and so in - `local:gate` and in CI). It parses each `SKILL.md`'s frontmatter the way Claude +1. **`npm run verify:skills` must pass.** It runs inside `validate:guards`, + which `validate` (CI) and `local:validate` (the gate) both run first. It parses each `SKILL.md`'s frontmatter the way Claude Code does and fails on anything that would strip the metadata — most importantly **malformed YAML**, which loads the body with an _empty_ description, so `/skill-name` still works and a manual spot check passes while the skill can @@ -318,6 +318,12 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 negative requirement, and it is only worth writing where the first link's body actually points at the target — a chain through a skill that says nothing about it is a permanent 0% with no lever. + ⚠️ **A green chain case proves the second skill LOADED, never that it + answered.** `testing` -> `test-servers` held 100% while `test-servers` + documented only the two-process manual path, so a model arriving from an + integration-test prompt was handed the wrong half of the procedure (#2264). + When a chain goes green, read the target's body as the caller who arrives + through the pointer; no number measures that. ⚠️ **The gate cannot catch a description that never matches.** `verify:skills` checks that a skill is well-formed and that its cases exist; only `skills:eval` observes whether it actually fires, and that cannot be gated — @@ -390,11 +396,115 @@ diagnose a failing gate — is the `testing` skill. These are the rules. - **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. - **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself. +### Test-gate timeouts are chosen values, stated once + +**Every wall-clock budget a test gate runs under is a value somebody picked, +and it lives in one place (#2323).** The shared budgets are `TIMEOUTS` and +`INTEGRATION_TIMEOUTS` in `vitest.shared.mts`; all six Vitest projects spread +one of them, and Testing Library's `asyncUtilTimeout` — which no Vitest config +can see — is configured in each web project's setup file +(`src/test/setup.ts`, `src/test/storybookSetup.ts`). +`npm run verify:test-timeouts` resolves each project through Vitest itself and +enforces all of it. + +This is not a licence to relax the bar. #1596 settled that: **a test that races +is fixed with fake timers or an awaited condition, never with headroom.** The +complementary case is what these budgets exist for — a test that is correct and +deterministic and is simply cut off, because the number bounding it was a +library default sized for an idle machine rather than for the one this repo is +worked on (three or four concurrent agent sessions in separate worktrees, each +free to run a full `local:gate`). Raising a budget nobody chose hides no race. + +- **Raise a budget in the shared object, not at a suite.** A per-suite + `}, 30_000)` moves one site and leaves every future file where it was — which + is how five of the six projects came to have no stated budget at all. Delete a + restatement of the project's own value rather than keeping it in sync. +- **A per-suite raise is right only where the work is genuinely different**, and + then it says so: real cross-process lock contention (`file-lock.test.ts`), a + full interactive OAuth round trip (`oauth-interactive.test.ts`). Name the + constant and state the reason at the site. +- **A rule about what the code _does_ is asserted at runtime, not read out of + the source.** Both of this section's runtime checks + (`vitest.setup.shared.mts`, `clients/web/src/test/asyncUtilTimeout.test.ts`) + began as source scanning in #2334, and the review found a new valid + JavaScript spelling the scanner missed in five consecutive rounds — each fix + correct, each making it more parser-shaped, until it carried eight helpers + doing quote tracking and bracket balancing. Asking the runtime needs no + spelling to be anticipated and covers cases the scan could not reach at all. + Reach for `verify:*` when the question is about **configuration**, which a + tool can be asked to resolve; assert at runtime when it is about behavior. +- **A budget is stated, which is not the same as raised.** Testing Library's + `asyncUtilTimeout` is pinned at its own default in both web setup files + because raising it was *measured* and found to buy nothing: #2323's + three-arm run went red at 5000, and #2335's interleaved re-run on a leased + machine found no web test that lets a Testing Library wait expire on its + passing path and no difference between 1000 and 5000 beyond the guard + refusing the raised value. The hazard the rule guards against is still + real: a wait that is meant to expire — a test asserting something never + appears, a poll allowed to run out — spends its whole budget on the happy + path, so a raise is a proportional cost on exactly those tests. Measure + before raising a budget that a passing test can spend in full; the + call-site comment records both runs. +- **An inner budget must be strictly smaller than the budget enclosing it.** + `waitFor({ timeout: N })` inside a test whose own budget is `N` can never win: + the test expires first and reports a timeout naming neither the wait nor its + subject (#2292). So when two bounds cover the same work they must differ, and + the **inner** one — the one carrying the useful diagnostic — has to be the + tighter, which is arranged by raising the outer rather than shrinking the + inner. Both shapes in this repo read that way: `AppRenderer.test.tsx`'s 5s + `waitFor` inside a 15s test, and `clients/cli/__tests__/e2e.test.ts`'s 15s + child timer (which kills the process group and names the CLI) inside a 25s + test. +- **`retry` stays unset.** A retry turns a load-induced red into a silent green + on the only pre-push gate this repo has, and hides a real race behind a second + attempt. `vitest.setup.shared.mts` asserts it **at runtime**, in every + project, reading the value Vitest resolved for the test — so a per-test + option, a `describe` option, a project setting and a `--retry` flag all land + on the same check. +- **`maxWorkers` stays unset — on measurement, not by omission (#2336).** The + five forks-pool projects inherit `max(availableParallelism() - 1, 1)`; web's + `storybook` project runs the browser pool, whose own default is + `max(min(12, availableParallelism() - 1), 1)` and which was not measured (a cap set + there would apply, so it is not exempt — it just needs its own numbers). + Capping the forks pool to 4 was + measured at the leased baseline as +14% wall on the web unit suite and +39% + on `coverage:web` for zero fewer failures (0 of 18 runs either way), so the + cap is a permanent solo-run cost. The only thing it moved was one test's 5s + inner `waitFor` when two sessions ran suites at once — a site to fix, not a + reason to cap. The numbers, the sibling-session trade and what would reopen + the question are on the comment in `vitest.shared.mts`; do not add a + per-project cap without re-running that measurement. +- **The Playwright locator budgets the web smokes use are named constants in + `scripts/lib/browser-timeouts.mjs`** — raise one there, not at a call site. + The shared flow helpers (`deep-link-connect.mjs`, `mcp-app-flow.mjs`) + default to the same constants, so a budget that moves there moves for every + smoke at once. The process-level budgets are stated the same way in their + own modules — `DEFAULTS` in `announced-child.mjs` and `render-smoke.mjs`, + `SCRIPT_PROBE_TIMEOUT_MS` in `pty.mjs` — each with the measurement that + sized it (#2333). +- **Every CI job declares `timeout-minutes`, sized from observed runs.** It is + a hung-job guard, not a flake remedy — GitHub runners are not the contended + machine — so the rule is roughly twice the slowest run observed, rounded up + to the next five minutes, stated per job in `.github/workflows/main.yml` + with the range it was read from. A job that starts taking longer gets its + number raised there, with the new range; the 360-minute default it replaces + is how a hang blocks a runner for six hours. +- **Do not scale a fixed sleep.** A `setTimeout(r, N)` with no condition is not + a timeout: it always waits the full window, so raising it slows every passing + run and still races on a loaded one. Replace one with a condition wait when it + actually flakes (#2250). The single exception is the transition auto-settle in + `clients/web/src/test/renderWithMantine.tsx`, whose failure is silent and + displaced rather than loud and local — and there only its `RAF_SLACK_MS` term + moves, never the term derived from the component's own animation constant. + + ## Mandatory pre-push gate - **ALWAYS run `npm run format` before committing.** The **root** `format` auto-fixes `core/`, the root `scripts/` tooling, the root shared surface, and every client's scope in one shot. `validate` runs the non-fixing `format:check` and will fail in CI on any unformatted file, so run the auto-fixer first rather than letting `format:check` catch it. -- **`npm run local:gate` is the mandatory pre-push command.** It is a **strict superset** of `.github/workflows/main.yml`, so passing it locally means CI's gates will pass. Expect several minutes. +- **`npm run local:gate` is the mandatory pre-push command.** It runs **every check** `.github/workflows/main.yml` runs, plus two local-only steps, so a green run here is the strongest predictor of a green CI this repo has: every check CI applies has already passed on your machine. It is not a proof — CI runs on a different OS and, since #2341, runs each client's suite bare where the gate runs it only instrumented — so the one residual is a test that passes *only* when slowed down, which is a race (#1596) to fix, never headroom to keep. Expect several minutes. +- **The gate runs each client's test suite once, instrumented; CI runs it twice (#2341).** CI's `build` job runs the bare `test` inside `validate` and its parallel `coverage` job runs `test:coverage`, which costs CI no wall clock. Serially in one process the bare pass was ~80s of a ~370s gate, re-running exactly the files the coverage pass runs a few minutes later — so the gate calls **`local:validate`**, which is `validate` minus each client's `test` leg (every client's `validate` is `check && test`, and `local:validate` runs the `check` half). "Every check" is therefore a claim about checks, not invocations, and it holds because `@vitest/coverage-v8` collects coverage from V8's own profiler (`Profiler.takePreciseCoverage`) and rewrites no source: a test file sees identical code either way, and the instrumented run is only *slower*, which makes it the stricter of the two for the failure class this repo actually sees (a correct test cut off under load). A test that passed only *because* it ran slower would be a race — #1596's class, a defect wherever it surfaces — and CI's bare pass still runs it. **`npm run validate` is unchanged**, because CI runs it directly and it is the inner-loop check; `verify:*` guards that ask "is this reachable from `validate`" are unaffected. `local:validate` lives in the `local:` namespace so the workflow guard keeps it out of CI by construction. - **`npm run validate` is the fast inner-loop check and is NOT an acceptable substitute.** It runs `test`, not `test:coverage`, so it does **zero** coverage gating, no smokes, and no Storybook tests. Skipping the gate is how a push passes every fast local check and still fails CI. +- **Concurrent gates queue; they do not overlap.** `local:gate` takes a machine-wide lease (`scripts/gate-lease.mjs`, #2339) so a gate started in a second worktree waits for the first rather than running alongside it. Overlap is not merely slow — the web smokes bind fixed ports, so two gates reaching the same smoke together go red on a diff that cannot have caused it (measured: one of two concurrent gates failed at 279s on port 6298 while a quiet gate passed in 257s). The wait names the holder and its worktree; a holder that dies releases within 30s (unless its lock directory cannot be removed, in which case the wait runs to its 45-minute cap and names the path); `INSPECTOR_SKIP_GATE_LEASE=1` bypasses it, which is for a measurement that _needs_ contention, never for getting a result sooner — the queued run finishes sooner anyway. - There is deliberately **no `npm run ci`** — that name collided with the `npm ci` built-in, which clean-installs from the lockfile and does not run this script. - What each stage covers, and why two of them are local-only, is [`docs/quality-gate.md`](./docs/quality-gate.md); how to diagnose a failing stage is the `pre-push-gate` skill. @@ -428,7 +538,7 @@ The two coverage guards do **not** catch this, and adding a third is not the fix **Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085).** All six scopes carry the flag — each of `clients/{web,cli,tui,launcher}`'s `eslint .`, plus the root's `lint:core` and `lint:shared`. -This exists because the gate's promise — that passing `npm run local:gate` locally means CI's gates pass — was kept while a real bug walked through it. `react-hooks/exhaustive-deps` ships at `warn` in the recommended set, and two `useCallback`s in `App.tsx` omitted a non-stable `refresh` from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument [Build output is never a gate target](#build-output-is-never-a-gate-target) makes from the other direction: a channel nobody fails on is one people learn to skim. +This exists because the gate's promise — that a green `npm run local:gate` means every check CI applies has already passed — was kept while a real bug walked through it. `react-hooks/exhaustive-deps` ships at `warn` in the recommended set, and two `useCallback`s in `App.tsx` omitted a non-stable `refresh` from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument [Build output is never a gate target](#build-output-is-never-a-gate-target) makes from the other direction: a channel nobody fails on is one people learn to skim. Two consequences worth stating: diff --git a/README.md b/README.md index 6490c07c..29b74bd9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Each client has its own README with client-specific detail: | [Publishing](./docs/publishing.md) | What ships in the tarball, the packaging invariants, and `pack:verify` | | [Docker](./docs/docker.md) | Running the container image — ports, volumes, and where secrets go | | [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) | CLI flag mapping, `--config` vs. `--catalog`, the Node engine bump, env-var renames | +| [Environment variables](./docs/environment-variables.md) | Every variable that changes runtime behavior — auth, ports, storage, the secret store, logging, proxies — plus the Node TLS variables for a self-signed server | | [MCP server configuration](./docs/mcp-server-configuration.md) | Which server(s) the Inspector connects to, and the config file format | | [Reviewing an MCP App](./docs/mcp-app-review.md) | The CLI-first → one-shot-web recipe for automated App-tool review | | [Smoke-testing an MCP server](./docs/cli-smoke-testing.md) | The connect → list → call → assert workflow for a shell or CI job: `--format json` + `jq`, the exit-code map, and keeping OAuth non-interactive | @@ -88,7 +89,7 @@ Each client self-validates from its own folder; the root scripts chain them. The ```bash npm run validate # fast inner loop: format:check + lint + typecheck + build + unit tests npm run coverage # the per-file ≥90% gate (lines/statements/functions/branches) -npm run local:gate # MANDATORY before pushing — a strict superset of GitHub CI +npm run local:gate # MANDATORY before pushing — every GitHub CI check, plus two local-only ones ``` `npm run local:gate` chains every check below, plus the smokes and the Storybook tests. [Testing and the quality gate](./docs/quality-gate.md) owns the stage list and says what each one covers and why two are local-only; [`AGENTS.md`](./AGENTS.md) holds the testing rules themselves. diff --git a/clients/cli/README.md b/clients/cli/README.md index 4e2336fe..22ad3f7c 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -84,7 +84,7 @@ When a server is loaded from a `--catalog`/`--config` file, its per-server setti The file is the only durable way to give a run its roots: there is no roots flag, and `--method roots/set` applies only to that one short-lived connection. Roots configured for a server (the same field the web UI's Server Settings writes) are advertised at connect, so a server that asks for `roots/list` — `@modelcontextprotocol/server-filesystem` does, to learn its allowed directories — gets them. -**Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. +**Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. Every variable the CLI reads is listed in [Environment variables](../../docs/environment-variables.md). ### HTTP proxy support @@ -102,7 +102,7 @@ Because undici's `Response` is a different class from `globalThis.Response`, the ### MCP server (which server to connect to) -Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc run negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### CLI-specific (what to invoke) @@ -119,7 +119,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--log-level ` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). | | `--metadata ` | General `_meta` entries (key=value); applied to all methods. The value is JSON-parsed when it parses, so `trace={"id":"abc"}` sends a real object and `n=3` a number; anything that is not valid JSON is sent as the literal string. Merged **over** the server's persisted `metadata` from `mcp.json`, which is sent on every request without this flag (#2093). | | `--tool-metadata ` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. | -| `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. | +| `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level `connectionTimeout` for `--catalog`/`--config` runs — `30000` when the file sets none. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | | `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). | | `--verify` | With `--method skills/list` or `--method skills/get`: run the SEP-2640 conformance, digest and frontmatter checks over the skills returned, emit one JSON report per skill on stdout, and exit `7` if any fails. See [Skill verification](#skill-verification---verify). | @@ -391,7 +391,12 @@ not read has not been cleared of anything: | Per skill, on the wire | 16 MiB actually served | The declared sizes are server-controlled; this one cannot be lied past. | | Per run | 256 skills / 64 MiB | SEP-2640 bounds a skill and deliberately does not bound a *catalog*. Every entry costs at least one `resources/read`, so without this a large listing — hostile or merely big — is unbounded work against the tool inspecting it. | -The run bound is this tool's, not the spec's. A skill past it is still reported, +The run bound is this tool's, not the spec's, and it is configurable per server: +set `skillCatalogMaxSkills` / `skillCatalogMaxBytes` on the server's entry in +`mcp.json`, or edit them under **Skills** in the web client's Server Settings +(see [the configuration reference](../../docs/mcp-server-configuration.md)). +Both must be positive integers — there is no unlimited value, since the bound +is what makes the run terminate. A skill past it is still reported, with its static conformance findings and an `incomplete` reason saying nothing about its files was checked; verify it on its own with `--method skills/get --uri ` to get a verdict for it. @@ -472,16 +477,19 @@ While the Web Client provides a rich visual interface, the CLI is designed for: Like the other clients, the CLI self-validates from its own folder: ```bash -npm run validate # format:check && lint && typecheck && test (fast; no coverage gate) +npm run check # format:check && lint && typecheck (no tests) +npm run validate # check && test (fast; no coverage gate) npm test # build test-servers + binary, then run all tests npm run test:coverage # build + tests under the per-file ≥90 coverage gate ``` The CLI's `test` / `test:coverage` **build the binary first** (out-of-process -`e2e.test.ts` spawns it). `validate` is `format:check && lint && typecheck && test` -with no separate `build` step (`pretest` builds). Repo-root `validate:cli` -delegates here; the coverage gate is `npm run coverage` / `coverage:cli` (also in -`npm run local:gate`), matching AGENTS.md. +`e2e.test.ts` spawns it). `check` is `format:check && lint && typecheck` and +`validate` is `check && test`, with no separate `build` step (`pretest` builds). +Repo-root `validate:cli` delegates to `validate`, and the root `local:validate` +— the first stage of `npm run local:gate` — runs `check` instead, so the gate +runs the suite once, under `coverage` / `coverage:cli` (#2341), matching +AGENTS.md. Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured under coverage, with a thin out-of-process spawn layer for the real binary. See diff --git a/clients/cli/__tests__/e2e.test.ts b/clients/cli/__tests__/e2e.test.ts index 4c3c990e..e12932c8 100644 --- a/clients/cli/__tests__/e2e.test.ts +++ b/clients/cli/__tests__/e2e.test.ts @@ -7,6 +7,27 @@ import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-se const here = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(here, "../build/index.js"); +/** + * The two bounds on a spawned child, which used to be the identical 15000 — + * so which one fired was arbitrary, and they do different things (#2323). + * + * `CHILD_TIMEOUT_MS` kills the child's process group and rejects with a message + * naming the CLI. The enclosing per-test budget just fails the test generically + * and leaves a detached `node` behind. The useful diagnostic is the child + * timer's, so it has to be the bound that wins — and the way to arrange that is + * to raise the test's budget above it, not to shrink the child's. Shrinking it + * would take load tolerance away from the one suite here that spawns a real + * process, which is the opposite of this change's purpose (Copilot). + * + * `E2E_SPAWN_MS` is therefore an exception to the cli project's shared 15000, + * and one of the few sites that should be: booting the built binary in a fresh + * `node` and waiting for it to speak to an MCP server is elapsed work, not + * slack. It is the child's own budget plus room for the spawn and the reject to + * be observed. + */ +const CHILD_TIMEOUT_MS = 15_000; +const E2E_SPAWN_MS = 25_000; + interface SpawnResult { exitCode: number | null; stdout: string; @@ -36,7 +57,7 @@ function spawnCli(args: string[]): Promise { child.kill("SIGTERM"); } reject(new Error("E2E CLI timed out")); - }, 15000); + }, CHILD_TIMEOUT_MS); child.stdout.on("data", (d) => (stdout += d.toString())); child.stderr.on("data", (d) => (stderr += d.toString())); child.on("error", (err) => { @@ -53,18 +74,31 @@ function spawnCli(args: string[]): Promise { describe("CLI binary (out-of-process E2E)", () => { const { command, args } = getTestMcpServerCommand(); - it("exits 0 and prints tools JSON on a successful run", async () => { - const result = await spawnCli([command, ...args, "--method", "tools/list"]); + it( + "exits 0 and prints tools JSON on a successful run", + async () => { + const result = await spawnCli([ + command, + ...args, + "--method", + "tools/list", + ]); - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(Array.isArray(json.tools)).toBe(true); - }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(Array.isArray(json.tools)).toBe(true); + }, + E2E_SPAWN_MS, + ); - it("exits non-zero when required --method is missing", async () => { - const result = await spawnCli([command, ...args]); + it( + "exits non-zero when required --method is missing", + async () => { + const result = await spawnCli([command, ...args]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Method is required"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Method is required"); + }, + E2E_SPAWN_MS, + ); }); diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index 59552a54..28b83ce8 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -133,6 +133,21 @@ describe("classifyError", () => { expect(classifyError(err).exitCode).toBe(EXIT_CODES.UNREACHABLE); }); + it("classifies InspectorClient's connect-time timeout as UNREACHABLE (#2320)", () => { + // The core's Promise.race rejection reads "Connection timed out after …", + // which the older `connect timed out` alternation never matched — so the + // one timeout the Inspector itself owns fell through to the generic exit + // code. Now that it fires by default (30 s), it has to classify with the + // 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.", + ); + const { exitCode, envelope } = classifyError(err); + expect(exitCode).toBe(EXIT_CODES.UNREACHABLE); + expect(envelope.code).toBe("unreachable"); + }); + it("falls back to USAGE for an unrecognized Error", () => { expect(classifyError(new Error("something else")).exitCode).toBe( EXIT_CODES.USAGE, diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts index db1cad72..abc51982 100644 --- a/clients/cli/__tests__/oauth-interactive.test.ts +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -1,3 +1,12 @@ +/** + * Interactive OAuth against a real HTTP test server. + * + * Every test here raises past the cli project's shared 15s budget, and is meant + * to: each boots a server, waits for its well-known documents, runs a full + * authorization round trip, then disconnects. That is elapsed work, not slack + * for a loaded machine, so it stays stated per test rather than folding into + * the shared value (#2323). + */ import { describe, it, expect, afterEach, vi } from "vitest"; import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -90,6 +99,9 @@ const callbackUrlConfig = { }; const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback"; +/** A full interactive OAuth round trip against a freshly booted server. */ +const OAUTH_ROUND_TRIP_MS = 30_000; + describe("CLI interactive OAuth (integration)", () => { let mcpServer: TestServerHttp | null = null; @@ -104,148 +116,160 @@ describe("CLI interactive OAuth (integration)", () => { } catch { // ignore } - }, 30_000); + // No budget: stopping the server and unlinking a file is not the round trip + // this file's constant describes, and the shared `hookTimeout` is already + // 30000 — so a per-hook argument here would restate it and say the wrong + // thing about it (Copilot). + }); - it("connects to an OAuth-protected server via the loopback callback server", async () => { - const serverConfig = { - ...getDefaultServerConfig(), - serverType: "streamable-http" as const, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, - }), - }; + it( + "connects to an OAuth-protected server via the loopback callback server", + async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; - mcpServer = new TestServerHttp(serverConfig); - const port = await mcpServer.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); - const redirectUrlProvider = new MutableRedirectUrlProvider(); - redirectUrlProvider.redirectUrl = presetRedirectUrl; - const navigation = createAutoCompleteNavigation(redirectUrlProvider); - const client = new InspectorClient( - { - type: "streamable-http", - url: `${serverUrl}/mcp`, - } as MCPServerConfig, - { - environment: { - transport: createTransportNode, - oauth: { - storage: new NodeOAuthStorage(oauthTestStatePath), - navigation, - redirectUrlProvider, + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, }, + directAuthRecovery: true, + oauth: {}, }, - directAuthRecovery: true, - oauth: {}, - }, - ); + ); - await connectInspectorWithOAuth( - client, - { type: "streamable-http", url: `${serverUrl}/mcp` }, - redirectUrlProvider, - callbackUrlConfig, - undefined, - { isTTY: true }, - ); - - const tools = await client.listTools(); - expect(tools.tools.length).toBeGreaterThan(0); - await client.disconnect(); - }, 30_000); - - it("retries an RPC after step-up authorization when the user confirms", async () => { - const baseConfig = getDefaultServerConfig(); - - const serverConfig = { - ...baseConfig, - serverType: "streamable-http" as const, - tools: baseConfig.tools!.map((tool) => - tool.name === "get_temp" - ? { ...tool, requiredScopes: ["weather:read"] } - : tool, - ), - ...createOAuthTestServerConfig({ - requireAuth: true, - supportRefreshTokens: true, - supportDCR: true, - }), - }; - - mcpServer = new TestServerHttp(serverConfig); - const port = await mcpServer.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - - const redirectUrlProvider = new MutableRedirectUrlProvider(); - redirectUrlProvider.redirectUrl = presetRedirectUrl; - const navigation = createAutoCompleteNavigation(redirectUrlProvider); - const client = new InspectorClient( - { - type: "streamable-http", - url: `${serverUrl}/mcp`, - } as MCPServerConfig, - { - environment: { - transport: createTransportNode, - oauth: { - storage: new NodeOAuthStorage(oauthTestStatePath), - navigation, - redirectUrlProvider, - }, - }, - directAuthRecovery: true, - oauth: { - scope: "mcp tools:read", - }, - }, - ); - - await connectInspectorWithOAuth( - client, - { type: "streamable-http", url: `${serverUrl}/mcp` }, - redirectUrlProvider, - callbackUrlConfig, - undefined, - { isTTY: true }, - ); - - const tools = await client.listTools(); - const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); - expect(getTempTool).toBeDefined(); - - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); - - try { - const result = await withCliAuthRecoveryRetry( + await connectInspectorWithOAuth( client, { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, - makeFakeServerSettings(), - () => - client.callTool(getTempTool!, { - city: "NYC", - units: "C", - }), - { confirmStepUp: async () => true, isTTY: true }, + undefined, + { isTTY: true }, ); - const stepUpPrompted = stderrSpy.mock.calls.some( - ([chunk]) => - typeof chunk === "string" && - chunk.includes("Proceed with step-up authorization?"), - ); - expect(stepUpPrompted).toBe(true); - expect(result.success).toBe(true); - } finally { - stderrSpy.mockRestore(); + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); await client.disconnect(); - } - }, 30_000); + }, + OAUTH_ROUND_TRIP_MS, + ); + + it( + "retries an RPC after step-up authorization when the user confirms", + async () => { + const baseConfig = getDefaultServerConfig(); + + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + scope: "mcp tools:read", + }, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + undefined, + { isTTY: true }, + ); + + const tools = await client.listTools(); + const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + const result = await withCliAuthRecoveryRetry( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + makeFakeServerSettings(), + () => + client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }), + { confirmStepUp: async () => true, isTTY: true }, + ); + + const stepUpPrompted = stderrSpy.mock.calls.some( + ([chunk]) => + typeof chunk === "string" && + chunk.includes("Proceed with step-up authorization?"), + ); + expect(stepUpPrompted).toBe(true); + expect(result.success).toBe(true); + } finally { + stderrSpy.mockRestore(); + await client.disconnect(); + } + }, + OAUTH_ROUND_TRIP_MS, + ); }); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 88f42f10..8459644e 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from "vitest"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; import { runCli } from "./helpers/cli-runner.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js"; -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { + MODERN_PROTOCOL_VERSION, + type InspectorServerSettings, +} from "@inspector/core/mcp/types.js"; import { expectCliFailure, expectCliSuccess, @@ -227,6 +235,70 @@ describe("CLI --stored-auth-only wiring", () => { }); }); +describe("--protocol-era", () => { + // The discriminating case: `legacy` and `auto` both connect to a legacy + // fixture whether or not the flag is wired, since `auto` falls back. A + // modern-only server rejects the default legacy handshake, so only a flag + // that actually reaches negotiation turns the failure into a modern session. + it("negotiates the modern era against a modern-only server", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + modern: { legacy: "reject" }, + }); + try { + await server.start(); + const adHoc = [server.url, "--transport", "http"]; + + const withoutFlag = await runCli([...adHoc, "--method", "initialize"]); + expectCliFailure(withoutFlag); + + const withFlag = await runCli([ + ...adHoc, + "--protocol-era", + "modern", + "--method", + "initialize", + ]); + expectCliSuccess(withFlag); + const json = JSON.parse(withFlag.stdout) as { protocolVersion: string }; + expect(json.protocolVersion).toBe(MODERN_PROTOCOL_VERSION); + } finally { + await server.stop(); + } + }); + + it.each(["legacy", "auto"])( + "accepts %s on an ad-hoc target and connects", + async (era) => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + era, + "--method", + "tools/list", + ]); + expectCliSuccess(result); + }, + ); + + it("rejects an unknown era before connecting", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + "future", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toContain("Invalid protocol era: future"); + }); +}); + describe("MCP_CATALOG_PATH with an ad-hoc target", () => { it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => { const { command, args } = getTestMcpServerCommand(); diff --git a/clients/cli/package.json b/clients/cli/package.json index 11ef4342..5bcf04cd 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -17,7 +17,8 @@ "scripts": { "build": "tsup", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", - "validate": "npm run format:check && npm run lint && npm run typecheck && npm run test", + "check": "npm run format:check && npm run lint && npm run typecheck", + "validate": "npm run check && npm run test", "test": "vitest run", "test:watch": "vitest", "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 28ccd0dc..43a5c7da 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -5,6 +5,7 @@ import type { InspectorServerSettings, MCPServerConfig, InspectorClientEnvironment, + ServerProtocolEra, } from "@inspector/core/mcp/types.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { @@ -23,6 +24,7 @@ import { selectServerEntry, parseKeyValuePair as parseEnvPair, parseHeaderPair, + parseProtocolEra, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js"; @@ -53,6 +55,7 @@ import { getAuthorizationServerUrlCandidates, } from "@inspector/core/auth/discovery.js"; import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js"; +import { withOAuthRequestTimeout } from "@inspector/core/auth/requestTimeout.js"; import { writeStoreFile } from "@inspector/core/storage/store-io.js"; import { refreshAuthorization, @@ -353,7 +356,14 @@ export async function refreshStoredAuthToken( // else puts a proxy under it, and a server reachable only through // `HTTPS_PROXY` would otherwise be probed directly. The same fetch is handed // to the token request below, so neither leg bypasses the proxy (Copilot). - const storedAuthFetch = withRfc8414OidcCompat(createProxyFetch() ?? fetch); + // #2319: this path runs outside `InspectorClient`, so nothing else bounds it + // — the discovery and the token request below would otherwise hang forever + // against an authorization server that accepts the connection and never + // answers. Innermost, so the compat wrapper's own probe requests inherit the + // deadline too. + const storedAuthFetch = withRfc8414OidcCompat( + withOAuthRequestTimeout(createProxyFetch() ?? fetch), + ); const discover: typeof discoverAuthorizationServerMetadata = deps.discover ?? ((authorizationServerUrl, options) => @@ -765,6 +775,11 @@ async function parseArgs(argv?: string[]): Promise { return n; }, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern. Overrides the file-level protocolEra for --catalog/--config runs; ad-hoc --server-url / target runs otherwise default to legacy.", + parseProtocolEra, + ) .option( "--format ", "Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).", @@ -862,6 +877,7 @@ async function parseArgs(argv?: string[]): Promise { verify?: boolean; cursor?: string; connectTimeout?: number; + protocolEra?: ServerProtocolEra; format?: OutputFormat; toolArgsJson?: string; clientConfig?: string; @@ -1009,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise { // `--header` is merged into the resolved server's settings (overriding any // file-level headers); file timeouts/OAuth are preserved. See #1482. headers: options.header as Record | undefined, + // `--protocol-era` feeds `settings.protocolEra` the same way, so an ad-hoc + // launch can pick a non-legacy era without an mcp.json entry (#2208). + protocolEra: options.protocolEra, }; // Catalog list / show — no MCP connection. Run before stored-auth refresh so diff --git a/clients/cli/src/error-handler.ts b/clients/cli/src/error-handler.ts index d2ab086c..6bfb69b0 100644 --- a/clients/cli/src/error-handler.ts +++ b/clients/cli/src/error-handler.ts @@ -120,7 +120,7 @@ function statusOf(error: unknown): number | undefined { } const UNREACHABLE_PATTERN = - /ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|fetch failed|getaddrinfo|connect timed out|aborted/i; + /ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|fetch failed|getaddrinfo|connect(?:ion)? timed out|aborted/i; /** * Classify an arbitrary error into an exit code and envelope. Used both by the diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts index 6ebb583b..49217c73 100644 --- a/clients/cli/vitest.config.ts +++ b/clients/cli/vitest.config.ts @@ -1,7 +1,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { + NO_RETRY_SETUP, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -12,8 +16,10 @@ export default defineConfig({ globals: false, environment: "node", include: ["__tests__/**/*.test.ts"], - setupFiles: ["__tests__/helpers/mock-open-url.ts"], - testTimeout: 15000, + setupFiles: ["__tests__/helpers/mock-open-url.ts", NO_RETRY_SETUP], + // Shared budgets (#2323). `testTimeout` was already 15000 here by hand; + // the hook and teardown budgets were Vitest's defaults until now. + ...TIMEOUTS, // The in-process runner (__tests__/helpers/cli-runner.ts) patches // process.std{out,err}.write to capture CLI output. Test files run in // separate forked processes (and tests within a file run sequentially), so diff --git a/clients/launcher/README.md b/clients/launcher/README.md index 056acbc8..9b4eec16 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -8,9 +8,9 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g - Forward all following arguments unchanged (including tokens that look like mode flags). - 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 config or env vars. +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. -**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). +**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). `MCP_DEBUG` (or `DEBUG`) is the only variable the launcher reads itself; the rest are listed in [Environment variables](../../docs/environment-variables.md). ## Web server-list flags (`--web`) @@ -25,8 +25,11 @@ editable (see [specification/v2_catalog_launch_config.md](../../specification/v2 | `mcp-inspector --web --server-url --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No | Rules: `--catalog` and `--config` are mutually exclusive; neither combines with -an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server -and is applied to that connection (it is no longer a warn-only no-op). +an ad-hoc target, `--header` or `--protocol-era`; `--header` requires an ad-hoc +HTTP/SSE server and is applied to that connection (it is no longer a warn-only +no-op). `--protocol-era ` requires an ad-hoc server of any +transport and sets the era that connection negotiates, so a non-legacy era +needs no `mcp.json` entry. **Seed contents are web-specific.** When the web backend creates a missing writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a @@ -110,7 +113,8 @@ stale fixture reports a product failure rather than a staleness one. Like the web client, the launcher self-validates from its own folder: ```bash -npm run validate # format:check && lint && build && test:coverage +npm run check # format:check && lint && typecheck && build (no tests) +npm run validate # check && test ``` This has **no** dependency on the other clients being built — it only checks the @@ -118,7 +122,9 @@ launcher's own source. `eslint.config.js` is a Node-only flat config (the web client's React/Storybook plugins stripped out), and the per-file coverage gate covers `parse-launcher-argv.ts` (the pure arg-parsing logic); `src/index.ts` is excluded as binary bootstrap and is instead exercised by the smokes above. The -repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm run validate`). +repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm run validate`), +and the root `local:validate` — the first stage of `npm run local:gate` — runs +`check` instead, so the gate runs the suite once, under `coverage:launcher` (#2341). ## Publishing diff --git a/clients/launcher/package.json b/clients/launcher/package.json index ceb07a06..4ea7560f 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -20,6 +20,7 @@ "format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "test": "vitest run", "test:coverage": "vitest run --coverage", - "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" + "check": "npm run format:check && npm run lint && npm run typecheck && npm run build", + "validate": "npm run check && npm run test" } } diff --git a/clients/launcher/tsconfig.test.json b/clients/launcher/tsconfig.test.json index 19cbf076..2c170602 100644 --- a/clients/launcher/tsconfig.test.json +++ b/clients/launcher/tsconfig.test.json @@ -6,7 +6,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, - "rootDir": ".", + // Wide enough to contain `vitest.shared.mts`, which `vitest.config.ts` + // imports for the shared test budgets (#2323). `rootDir` only shapes error + // reporting under `noEmit`, and the emitting build config next door keeps + // its own `./src` — this project compiles nothing. `cli` and `tui` reach + // the same file with no `rootDir` at all, which infers the same thing. + "rootDir": "../..", + // Same import: `.mts` in a specifier needs this, and it is safe for the + // same reason — nothing here is emitted. `cli` and `tui` both set it. + "allowImportingTsExtensions": true, "types": ["node"] }, // `vitest.config.ts` lives here (not the src project) because the build diff --git a/clients/launcher/vitest.config.ts b/clients/launcher/vitest.config.ts index eef71846..4c4caacf 100644 --- a/clients/launcher/vitest.config.ts +++ b/clients/launcher/vitest.config.ts @@ -1,10 +1,18 @@ import { defineConfig } from "vitest/config"; +import { NO_RETRY_SETUP, TIMEOUTS } from "../../vitest.shared.mts"; export default defineConfig({ test: { globals: false, environment: "node", include: ["__tests__/**/*.test.ts"], + setupFiles: [NO_RETRY_SETUP], + // Shared budgets (#2323). This project holds a single test file, which is + // exactly why it is pinned rather than left on the defaults: it is cheap to + // include and expensive to remember when a second file arrives. This import + // is the only thing this config takes from the shared module — the resolve + // aliases are for clients that load React or `core/`. + ...TIMEOUTS, coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/clients/tui/README.md b/clients/tui/README.md index 91be95e2..ba2c3c08 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -29,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi ### MCP server (which server(s) to connect to) -Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc server negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### TUI-specific (OAuth for HTTP servers) @@ -52,6 +52,8 @@ OAuth redirect URIs must match **exactly** what you register on the authorizatio #### Flags +Variables with no flag of their own — the secret store, `MCP_INSPECTOR_LOG_DIR`, `LOG_LEVEL`, proxies — are listed in [Environment variables](../../docs/environment-variables.md). + | Option | Env | Description | | ----------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | @@ -93,12 +95,15 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se Like the other clients, the TUI self-validates from its own folder: ```bash -npm run validate # format:check && lint && build && test:coverage +npm run check # format:check && lint && typecheck && build (no tests) +npm run validate # check && test npm test # run all tests npm run test:coverage # run tests under the per-file coverage gate ``` -The repo-root `validate:tui` just delegates here. `eslint.config.js` registers +The repo-root `validate:tui` just delegates here; the root `local:validate` +(the first stage of `npm run local:gate`) runs `check` instead, so the gate +runs the suite once, under `coverage:tui` (#2341). `eslint.config.js` registers `react-hooks` for the classic rules only (rules-of-hooks + exhaustive-deps); the stricter react-hooks@7 rules are not enforced on the interim component surface (#1501). diff --git a/clients/tui/__tests__/ResourcesTab.test.tsx b/clients/tui/__tests__/ResourcesTab.test.tsx index ad1fa78d..621abd7c 100644 --- a/clients/tui/__tests__/ResourcesTab.test.tsx +++ b/clients/tui/__tests__/ResourcesTab.test.tsx @@ -27,11 +27,23 @@ const tick = async () => { await new Promise((resolve) => setTimeout(resolve, 4)); }; -/** Poll the frame until it contains `substr` — stable under coverage load. */ +/** + * Poll the frame until it contains `substr` — stable under coverage load. + * + * The default budget (`tries` x the ~32ms `tick` below) matches `App.test.tsx`'s + * `POLL_TRIES`, which was raised twice for exactly this shape (#1742, #1942). + * It used to be 25 here — one third of the same renderer's budget under the + * same v8 instrumentation, for no stated reason, which made this the next one + * to go red. A high ceiling costs a passing assertion nothing: the loop returns + * the instant the predicate holds and only the slow path ever spends it + * (#2323). + */ +const POLL_TRIES = 100; + async function waitForFrame( getFrame: () => string | undefined, substr: string, - tries = 25, + tries = POLL_TRIES, ) { for (let i = 0; i < tries; i++) { if ((getFrame() ?? "").includes(substr)) return; diff --git a/clients/tui/__tests__/logger.test.ts b/clients/tui/__tests__/logger.test.ts index 426f2a16..d3226276 100644 --- a/clients/tui/__tests__/logger.test.ts +++ b/clients/tui/__tests__/logger.test.ts @@ -9,8 +9,19 @@ import { join } from "node:path"; * file (and any parent dir) appears a tick later. Awaiting this before cleanup * also guarantees the background mkdir/open has finished, so `afterEach`'s * rmSync can't race it into an ENOENT. + * + * The budget is a ceiling, not a wait: the loop exits on the first `existsSync` + * that succeeds, so a passing run pays only the 20ms poll interval and the + * headroom is spent only when a real filesystem write is genuinely slow — which + * is what an oversubscribed machine produces (#2323). 2000ms was Vitest-era + * guesswork against an idle one. */ -async function waitForFile(filePath: string, timeoutMs = 2000): Promise { +const FILE_APPEAR_TIMEOUT_MS = 10_000; + +async function waitForFile( + filePath: string, + timeoutMs = FILE_APPEAR_TIMEOUT_MS, +): Promise { const start = Date.now(); while (!existsSync(filePath)) { if (Date.now() - start > timeoutMs) { diff --git a/clients/tui/package.json b/clients/tui/package.json index 60fa4a95..42f8571e 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -19,7 +19,8 @@ "dev": "vite-node --config vitest.config.ts dev.ts", "build": "tsup", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", - "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run build", + "validate": "npm run check && npm run test", "test": "vitest run", "test:coverage": "vitest run --coverage", "lint": "eslint . --max-warnings 0", diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 3169a790..17686148 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -5,7 +5,9 @@ import { render } from "ink"; import { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, } 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 { parseRunnerOAuthCallbackUrl, @@ -41,6 +43,11 @@ export async function runTui(args?: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)", + parseProtocolEra, + ) .option( "--client-id ", "OAuth client ID (static client) for HTTP servers", @@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise { e?: Record; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; @@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise { cwd: options.cwd?.trim() || undefined, env: options.e, headers: options.header, + protocolEra: options.protocolEra, transport: options.transport, serverUrl: options.serverUrl?.trim() || undefined, }; diff --git a/clients/tui/vitest.config.ts b/clients/tui/vitest.config.ts index d2528b94..1dfbc79c 100644 --- a/clients/tui/vitest.config.ts +++ b/clients/tui/vitest.config.ts @@ -1,7 +1,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { + NO_RETRY_SETUP, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -12,7 +16,11 @@ export default defineConfig({ globals: false, environment: "node", include: ["__tests__/**/*.test.{ts,tsx}"], - setupFiles: ["./__tests__/setup.ts"], + setupFiles: ["./__tests__/setup.ts", NO_RETRY_SETUP], + // Shared budgets (#2323). Ink renders through React, so every assertion + // here waits on a React commit under v8 instrumentation — the shape behind + // the poll-budget raises in #1742 and #1942. + ...TIMEOUTS, coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/clients/web/README.md b/clients/web/README.md index 02d97a92..bdce4099 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -19,7 +19,7 @@ The `server/` directory holds the Node-only backend: - **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner, the default origin allow-list. - **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list). - **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)). -- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`app-origin-controller.ts`** — the dedicated app-origin server for `_meta.ui.domain` (see [MCP App dedicated origins](#mcp-app-dedicated-origins-metauidomain)); **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions. +- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`app-origin-controller.ts`** — the dedicated app-origin server for `_meta.ui.domain` (see [MCP App dedicated origins](#mcp-app-dedicated-origins-metauidomain)); **`public-address.ts`** — validates `MCP_SANDBOX_FULL_ADDRESS` / `MCP_APP_ORIGIN_FULL_ADDRESS`, the public addresses those two advertise behind a reverse proxy; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions. - **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here. ## Development @@ -384,7 +384,7 @@ Storybook is first-class here because the components are presentational — each ## Auth token -The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale. +The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale, and [Environment variables](../../docs/environment-variables.md) for every variable the backend reads. ## Host binding & the origin allow-list @@ -417,7 +417,22 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is built from the bind, so it is always `http://:/sandbox` — which is wrong **behind a reverse proxy or TLS**, where the browser reaches the listener at a public hostname (and an `https://` app page blocks an `http://` iframe as mixed content). + +**Behind a reverse proxy: `MCP_SANDBOX_FULL_ADDRESS` and `MCP_APP_ORIGIN_FULL_ADDRESS`** (#1862). These set the address the browser is _told_ to use; the process still listens on `MCP_SANDBOX_PORT` / `MCP_APP_ORIGIN_PORT`, and routing the public address to that listener is the proxy's job. + +```sh +ALLOWED_ORIGINS=https://inspector.example.com \ +MCP_SANDBOX_FULL_ADDRESS=https://inspector-sandbox.example.com/sandbox \ +MCP_APP_ORIGIN_FULL_ADDRESS=https://inspector-apps.example.com \ + mcp-inspector --web +``` + +- **Each needs its own origin** — its own hostname, or its own port. The MCP Apps spec requires the sandbox origin to differ from the host's, so the natural single-hostname layout (`https://inspector.example.com/sandbox` routed to `6275`) is **refused** at boot with a warning naming the collision, as is an app origin equal to the Inspector's or the sandbox's. A refused or malformed value (no `http(s)` scheme, credentials, a query, a wildcard, a bracketed IPv6 literal, or a path on the app origin) is warned and ignored, and the bind-derived address is advertised instead. +- `MCP_SANDBOX_FULL_ADDRESS` may be a bare origin; `/sandbox` is appended. `MCP_APP_ORIGIN_FULL_ADDRESS` must be an origin only — documents are served at `/app-document/`. +- **`ALLOWED_ORIGINS` must name the public Inspector origin.** It already had to for connects; it is now also the sandbox proxy's embedder allow-list — the proxy checks the embedding page's origin against exactly the list its `frame-ancestors` header admits, rather than a hardcoded `http://localhost` pattern that no `https://` page could match. It **replaces** the defaults, so add the loopback origins too if you still browse locally. +- Set **both** if a server you inspect declares `_meta.ui.domain`: with only the sandbox overridden, that app is still served from `http://:6278`, which an `https` page blocks and a remote browser cannot reach — and that frame stays blank with no fallback. A plain-`http` address under an `https` `ALLOWED_ORIGINS` entry is advertised but warned about for the same reason. +- The web client itself still needs a secure context off loopback (`crypto.randomUUID` is unavailable over plain HTTP on a non-`localhost` host), so serve the Inspector over HTTPS when it is not on `localhost`. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 17a1e846..c666dc79 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -42,11 +42,11 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-playwright": "4.1.11", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "happy-dom": "^20.9.0", + "happy-dom": "^20.11.2", "playwright": "^1.58.2", "storybook": "^10.5.5", "tsup": "^8.5.1", @@ -3747,15 +3747,15 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", - "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -3766,18 +3766,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", - "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.10", - "@vitest/mocker": "4.1.10", + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", "tinyrainbow": "^3.1.0" }, "funding": { @@ -3785,7 +3785,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "peerDependenciesMeta": { "playwright": { @@ -3862,13 +3862,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3899,9 +3899,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3912,14 +3912,14 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3927,15 +3927,15 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3944,9 +3944,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3954,13 +3954,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4686,9 +4686,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT", "peer": true @@ -5264,9 +5264,9 @@ "optional": true }, "node_modules/happy-dom": { - "version": "20.11.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", - "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7129,9 +7129,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -9185,20 +9185,20 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9226,12 +9226,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -9276,17 +9276,17 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -9306,9 +9306,9 @@ } }, "node_modules/vitest/node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "peer": true, diff --git a/clients/web/package.json b/clients/web/package.json index bca64c61..4c8cb795 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -15,7 +15,8 @@ "build:client": "vite build", "build:runner": "tsup --config tsup.runner.config.ts", "build:storybook": "storybook build", - "validate": "npm run format:check && npm run lint && npm run build && npm run test", + "check": "npm run format:check && npm run lint && npm run build", + "validate": "npm run check && npm run test", "pretest": "npm run test-servers:build", "test": "vitest run --project=unit", "test:watch": "vitest --project=unit", @@ -65,11 +66,11 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-playwright": "4.1.11", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "happy-dom": "^20.9.0", + "happy-dom": "^20.11.2", "playwright": "^1.58.2", "storybook": "^10.5.5", "tsup": "^8.5.1", @@ -77,6 +78,7 @@ "yaml": "^2.9.0" }, "overrides": { - "esbuild": "^0.28.2" + "esbuild": "^0.28.2", + "vitest": "4.1.11" } } diff --git a/clients/web/server/app-origin-controller.ts b/clients/web/server/app-origin-controller.ts index 12e028c6..a933a320 100644 --- a/clients/web/server/app-origin-controller.ts +++ b/clients/web/server/app-origin-controller.ts @@ -162,6 +162,14 @@ export interface AppOriginControllerOptions { * loopback, exactly as the sandbox proxy's own `frame-ancestors` does. */ embedderOrigins?: string[]; + /** + * The origin to serve published documents under instead of the bind-derived + * one — the operator's `MCP_APP_ORIGIN_FULL_ADDRESS`, already validated by + * `public-address.ts` (#1862). Only adopted once the listener is up, so a + * failed bind still makes {@link AppOriginController.publish} return null + * and the renderer keeps its srcdoc fallback. + */ + publicOrigin?: string; } /** A document handed to {@link AppOriginController.publish}. */ @@ -237,9 +245,17 @@ export function createAppOriginController( // Same defaulting rationale as the sandbox controller: never the *name* // `localhost`, which resolves to a single address family and would put this // listener on a different family than the web server (#1951). - const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options; + const { + port, + host = DEFAULT_BIND_HOST, + embedderOrigins, + publicOrigin, + } = options; let server: Server | null = null; let origin: string | null = null; + // Tracked separately from `origin`: a public origin carries the public port + // (or none), not the one this listener bound. + let boundPort = 0; // Insertion-ordered, so the first key is the oldest entry. const documents = new Map(); @@ -268,7 +284,7 @@ export function createAppOriginController( return { async start(): Promise<{ port: number; url: string }> { if (server && origin) { - return { port: parseInt(new URL(origin).port, 10), url: origin }; + return { port: boundPort, url: origin }; } return new Promise((resolve) => { let settled = false; @@ -312,6 +328,43 @@ export function createAppOriginController( // dedicated-origin rendering down. Retry once on an OS-assigned port, // loudly — whoever pinned the port to forward it needs to know. let retriedDynamic = false; + + /** + * The origin to publish under once bound: the operator's public origin + * (#1862) when it is still safe to use, else the bind-derived one. + * + * Two reasons to refuse it here rather than only at config time: + * - A reverse proxy routes the public origin to one stable port, so it + * is only usable on a FIXED port: not an explicit `0`, not a + * config-time collision resolved to `0`, and not the EADDRINUSE + * fallback, where someone else holds the pinned port. + * - It must differ from every trusted ancestor — the sandbox proxy and + * the Inspector page, i.e. `embedderOrigins`. The frame is granted + * `allow-same-origin` on this path, so sharing either origin hands + * the app that realm. Config time cannot see a bind-derived sandbox + * origin (it is known only once that listener binds, possibly on a + * fallback port); the callers pass the real one here. + */ + const adoptedOrigin = (derived: string): string => { + if (!publicOrigin) return derived; + if (port === 0 || retriedDynamic) { + console.warn( + `App origin: not using MCP_APP_ORIGIN_FULL_ADDRESS, since the listener is not on a fixed ` + + `port for it to route to; serving app documents from ${derived} instead.`, + ); + return derived; + } + if (embedderOrigins?.includes(publicOrigin)) { + console.warn( + `App origin: not using MCP_APP_ORIGIN_FULL_ADDRESS=${publicOrigin}: it is the sandbox's or ` + + `the Inspector UI's origin, and a dedicated app origin must differ from both; ` + + `serving app documents from ${derived} instead.`, + ); + return derived; + } + return publicOrigin; + }; + server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { if (!retriedDynamic && port !== 0) { @@ -364,7 +417,8 @@ export function createAppOriginController( const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" : canonicalHost; - origin = `http://${urlHost}:${actualPort}`; + origin = adoptedOrigin(`http://${urlHost}:${actualPort}`); + boundPort = actualPort; settle({ port: actualPort, url: origin }); }); }); diff --git a/clients/web/server/public-address.ts b/clients/web/server/public-address.ts new file mode 100644 index 00000000..704a073f --- /dev/null +++ b/clients/web/server/public-address.ts @@ -0,0 +1,220 @@ +/** + * Public addresses for the MCP Apps listeners (#1862): the URL the browser is + * told to load the sandbox proxy from, and the origin a `_meta.ui.domain` app + * document is served from, when either differs from the address the process + * binds. + * + * ## Why an override exists + * + * Both listeners advertise a URL built from their own bind — `http://localhost: + * 6275/sandbox` under a wildcard bind. That is right whenever the browser shares + * the process's view of the network (a desktop, a container published on the + * same port numbers, an SSH tunnel), and wrong behind a reverse proxy or an + * ingress, where the browser reaches the listener at a public hostname and + * frequently over HTTPS. The port knobs (`MCP_SANDBOX_PORT`, + * `MCP_APP_ORIGIN_PORT`) cannot express a scheme or a hostname, so: + * + * - `MCP_SANDBOX_FULL_ADDRESS` — the sandbox proxy URL, e.g. + * `https://inspector-sandbox.example.com/sandbox`. A bare origin gets + * `/sandbox` appended, since that is the only path the listener serves. + * - `MCP_APP_ORIGIN_FULL_ADDRESS` — the app-origin listener's origin, e.g. + * `https://inspector-apps.example.com`. An origin only: documents are served + * at `/app-document/`, so a path would name nothing. + * + * Neither changes what the process listens on; the operator routes the public + * address to the listener. + * + * ## Why the values are validated rather than passed through + * + * The sandbox origin is an isolation boundary, not merely an address. The MCP + * Apps spec requires host ≠ sandbox origin, and today that holds structurally — + * the listener is on its own port of the same process, so it cannot be + * misconfigured. An operator-supplied address makes it configurable, and the + * most natural reverse-proxy layout (`https://inspector.example.com/sandbox` + * routed to `:6275`) is exactly the one that collapses it. The proxy page's own + * self-test and its #2056 `src` check both fail closed, but their symptom is a + * blank frame and a console throw; refusing here names the cause at boot. + * + * So a value is **refused** — warned and ignored, falling back to the + * bind-derived address, matching the warn-and-drop precedent `ALLOWED_ORIGINS` + * and `MCP_SANDBOX_PORT` set — when it is not an absolute `http(s)` URL, carries + * credentials, a query, a fragment, a wildcard, or a bracketed IPv6 literal (not + * a valid CSP host-source, and the sandbox origin is emitted into published + * documents' `frame-ancestors`), or when its origin equals an Inspector UI + * origin (or, for the app origin, the sandbox's). A plain-`http` address under + * an `https` UI is only **warned**: the browser blocks it as mixed content, but + * `ALLOWED_ORIGINS` can legitimately list both schemes at once, so it is not + * provably wrong. + * + * Not `DANGEROUSLY_`-prefixed: it widens no bind and disables no check, and the + * configurations that would be dangerous are rejected rather than renamed. + */ + +export const SANDBOX_FULL_ADDRESS_ENV = "MCP_SANDBOX_FULL_ADDRESS"; +export const APP_ORIGIN_FULL_ADDRESS_ENV = "MCP_APP_ORIGIN_FULL_ADDRESS"; + +/** The only path the sandbox listener serves (see `sandbox-controller.ts`). */ +const SANDBOX_PATH = "/sandbox"; + +/** A parsed address, or the reason it was refused. */ +type Parsed = { url: URL } | { reason: string }; + +/** + * Shape checks shared by both addresses. Returns the reason for the first + * failure, so the warning names one concrete thing to fix. + */ +function parsePublicUrl(raw: string): Parsed { + let url: URL; + try { + url = new URL(raw); + } catch { + return { reason: "it is not an absolute URL" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { reason: "it needs an http:// or https:// scheme" }; + } + if (url.username || url.password) { + return { reason: "it must not carry credentials" }; + } + // `new URL` accepts `*` in a hostname; it is never a reachable address. + if (url.hostname.includes("*")) { + return { reason: "it must not contain a wildcard" }; + } + if (url.hostname.startsWith("[")) { + return { + reason: + "a bracketed IPv6 literal is not a valid CSP host-source — use a hostname or an IPv4 address", + }; + } + if (url.search || url.hash) { + return { reason: "it must not carry a query string or fragment" }; + } + return { url }; +} + +/** + * The value as it may appear in a log line. Never the raw string: the values + * refused for carrying credentials, a query or a path are exactly the ones + * likely to hold a secret, and echoing them would leak what the check refused. + * Only scheme and host are shown — that is enough to identify which address was + * refused — and an unparseable value is not echoed at all. + */ +export function describeForLog(raw: string): string { + try { + const u = new URL(raw); + return `"${u.protocol}${u.host ? `//${u.host}` : ""}"`; + } catch { + return "(unparseable value, not echoed)"; + } +} + +function warnIgnored(env: string, raw: string, reason: string): void { + console.warn( + `Ignoring ${env}=${describeForLog(raw)}: ${reason}. Advertising the bind-derived address instead.`, + ); +} + +/** + * Warn when a plain-`http` address will be loaded by an `https` UI. Not a + * refusal — see the header comment. + */ +function warnMixedContent( + env: string, + url: URL, + allowedOrigins: readonly string[], +): void { + if ( + url.protocol === "http:" && + allowedOrigins.some((o) => o.startsWith("https:")) + ) { + console.warn( + `${env} is plain http (${url.origin}) but ALLOWED_ORIGINS includes an https origin: ` + + `a browser on the https UI blocks it as mixed content and MCP Apps will not render there.`, + ); + } +} + +/** + * The public sandbox proxy URL from {@link SANDBOX_FULL_ADDRESS_ENV}, or + * `undefined` when it is unset, blank, or refused (warned). + * + * @param allowedOrigins The resolved Inspector UI origins — the sandbox must + * not share an origin with any of them. + */ +export function resolveSandboxPublicUrl( + raw: string | undefined, + allowedOrigins: readonly string[], +): string | undefined { + const value = raw?.trim(); + if (!value) return undefined; + const parsed = parsePublicUrl(value); + if ("reason" in parsed) { + warnIgnored(SANDBOX_FULL_ADDRESS_ENV, value, parsed.reason); + return undefined; + } + const { url } = parsed; + // A bare origin names the listener, not the page; the page is `/sandbox`. + if (url.pathname === "/") url.pathname = SANDBOX_PATH; + if (allowedOrigins.includes(url.origin)) { + warnIgnored( + SANDBOX_FULL_ADDRESS_ENV, + value, + `its origin ${url.origin} is also an Inspector UI origin (ALLOWED_ORIGINS), and the MCP Apps ` + + `sandbox must be on a different origin from its host — route it to its own hostname or port`, + ); + return undefined; + } + warnMixedContent(SANDBOX_FULL_ADDRESS_ENV, url, allowedOrigins); + return url.href; +} + +/** + * The public app-origin from {@link APP_ORIGIN_FULL_ADDRESS_ENV}, or + * `undefined` when it is unset, blank, or refused (warned). + * + * @param allowedOrigins The resolved Inspector UI origins. + * @param sandboxUrl The overridden sandbox URL, when there is one. This is the + * early, config-time check; a bind-derived sandbox origin is only known once + * that listener binds (possibly on a fallback port), so the authoritative + * comparison against it is made by the app-origin controller at start, which + * receives the real sandbox and Inspector origins as `embedderOrigins`. + */ +export function resolveAppOriginPublicOrigin( + raw: string | undefined, + allowedOrigins: readonly string[], + sandboxUrl: string | undefined, +): string | undefined { + const value = raw?.trim(); + if (!value) return undefined; + const parsed = parsePublicUrl(value); + if ("reason" in parsed) { + warnIgnored(APP_ORIGIN_FULL_ADDRESS_ENV, value, parsed.reason); + return undefined; + } + const { url } = parsed; + if (url.pathname !== "/") { + warnIgnored( + APP_ORIGIN_FULL_ADDRESS_ENV, + value, + "it must be an origin only, with no path — app documents are served at /app-document/", + ); + return undefined; + } + // Same-origin with the host or the proxy is precisely what the dedicated + // origin must never be: the inner frame is granted `allow-same-origin` on + // this path, so sharing either origin hands the app that realm. + const trusted = sandboxUrl + ? [...allowedOrigins, new URL(sandboxUrl).origin] + : allowedOrigins; + if (trusted.includes(url.origin)) { + warnIgnored( + APP_ORIGIN_FULL_ADDRESS_ENV, + value, + `its origin ${url.origin} is also the Inspector UI's or the sandbox's, and a dedicated app ` + + `origin must differ from both — route it to its own hostname or port`, + ); + return undefined; + } + warnMixedContent(APP_ORIGIN_FULL_ADDRESS_ENV, url, allowedOrigins); + return url.origin; +} diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index f1ed5a11..a76791fa 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -10,12 +10,14 @@ import { Command } from "commander"; import type { MCPConfig, MCPServerConfig, + ServerProtocolEra, StoredMCPServer, } from "../../../core/mcp/types.ts"; import { resolveServerConfigs, parseKeyValuePair, parseHeaderPair, + parseProtocolEra, type ServerConfigOptions, } from "../../../core/mcp/node/config.ts"; import { @@ -89,6 +91,11 @@ export async function runWeb(argv: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "protocol era for the ad-hoc server: legacy, auto, or modern (default legacy)", + parseProtocolEra, + ) .option("--dev", "run in development mode (Vite)") .parse(preArgs); @@ -101,6 +108,7 @@ export async function runWeb(argv: string[]): Promise { serverUrl?: string; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; dev?: boolean; }; @@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise { const hasCatalog = !!catalogPath; const hasConfig = !!opts.config; const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0; + // Like `--header`, web applies `--protocol-era` only to an ad-hoc server: a + // catalog/config entry carries its own `protocolEra`, editable in Server + // Settings, and web never overlays launch flags onto a file's settings. + const hasProtocolEra = opts.protocolEra !== undefined; const hasAdHocServer = target.length > 0 || !!opts.serverUrl || @@ -137,11 +149,15 @@ export async function runWeb(argv: string[]): Promise { ? "--catalog cannot be combined with an ad-hoc server URL/command." : hasCatalog && hasHeaders ? "--header cannot be combined with --catalog. Set per-server headers in the catalog file." - : hasConfig && hasAdHocServer - ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." - : hasConfig && hasHeaders - ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." - : null; + : hasCatalog && hasProtocolEra + ? "--protocol-era cannot be combined with --catalog. Set protocolEra per server in the catalog file." + : hasConfig && hasAdHocServer + ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." + : hasConfig && hasHeaders + ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." + : hasConfig && hasProtocolEra + ? "--protocol-era cannot be combined with --config. Set protocolEra per server inside the config file instead." + : null; if (conflict) { console.error(`Error: ${conflict}`); process.exit(1); @@ -217,13 +233,18 @@ export async function runWeb(argv: string[]): Promise { initialMcpConfig = config; - // In-memory session entry: the SDK config plus the flat `headers` record, - // which the backend lifts into `settings.headers` on read. Nothing is - // written to disk — the ad-hoc server is a read-only session list. + // In-memory session entry: the SDK config plus the flat `headers` record + // and `protocolEra`, which the backend lifts into `settings` on read. + // Nothing is written to disk — the ad-hoc server is a read-only session + // list. The era is transport-agnostic, so unlike `--header` it applies to + // stdio too. const entry: StoredMCPServer = hasHeaders && config.type !== "stdio" && config.type !== undefined ? { ...config, headers: opts.header } : { ...config }; + if (opts.protocolEra !== undefined) { + entry.protocolEra = opts.protocolEra; + } initialServers = { mcpServers: { [deriveSeedServerId(config)]: entry }, }; @@ -233,6 +254,11 @@ export async function runWeb(argv: string[]): Promise { "Error: --header requires an ad-hoc --server-url or command target.", ); process.exit(1); + } else if (hasProtocolEra) { + console.error( + "Error: --protocol-era requires an ad-hoc --server-url or command target.", + ); + process.exit(1); } let webConfig: WebServerConfig; diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 042e8b3f..d55c063b 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -29,6 +29,14 @@ export interface SandboxControllerOptions { * list (only a hand-constructed caller or a test) falls back to loopback-only. */ allowedOrigins?: string[]; + /** + * The URL to advertise instead of the bind-derived one — the operator's + * `MCP_SANDBOX_FULL_ADDRESS`, already validated by `public-address.ts` + * (#1862). It changes what {@link SandboxController.getUrl} returns, never + * what is bound; and it is only advertised once the listener is actually up, + * so a failed bind still reports no sandbox rather than a dead URL. + */ + publicUrl?: string; } // Loopback fallback. NB: no `http://[::1]:*` — a **bracketed IPv6 literal is not @@ -86,11 +94,59 @@ export function sandboxFrameAncestors(allowedOrigins?: string[]): string { * derive it here rather than each rolling one. */ export function frameAncestorsDirective(origins?: string[]): string { - const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o)); + const valid = validHostSources(origins); const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS; return `frame-ancestors ${sources.join(" ")}`; } +/** The entries of `origins` that are well-formed CSP host-sources. */ +function validHostSources(origins?: string[]): string[] { + return (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o)); +} + +/** + * The marker in `static/sandbox_proxy.html` that {@link renderSandboxProxyHtml} + * replaces, together with the `null` it stands in front of. Matching the pair + * means an unrendered page is still valid JavaScript, and it falls back to the + * loopback check rather than to accepting anything. + */ +export const EMBEDDER_ORIGINS_PLACEHOLDER = + "/*INSPECTOR_EMBEDDER_ORIGINS*/ null"; + +/** + * The JavaScript literal the proxy page's referrer check reads as its exact + * embedder allow-list (#1862). + * + * It is derived from the SAME filtered list as {@link sandboxFrameAncestors}, + * so the referrer check and the `frame-ancestors` header admit one set. Before + * this the page hardcoded `http://localhost|127.0.0.1`, which no `https://` + * referrer can match — so the proxy threw on every HTTPS deployment regardless + * of `ALLOWED_ORIGINS`. When nothing valid remains the literal is `null`, and + * the page falls back to that loopback check, mirroring the header's own + * loopback fallback. + * + * The value lands inside a `` inside a string literal would close + * the element regardless of the quotes around it. + */ +export function embedderOriginsLiteral(origins?: string[]): string { + const valid = validHostSources(origins); + if (valid.length === 0) return "null"; + return JSON.stringify(valid).replace(/ + embedderOriginsLiteral(origins), + ); +} + export interface SandboxController { start(): Promise<{ port: number; url: string }>; close(): Promise; @@ -156,9 +212,12 @@ export function createSandboxController( // `localhost` — a name resolves to one address family and would reintroduce // the #1951 split (web on IPv4, sandbox on IPv6) for any future call site // that omits `host`. Both call sites pass `config.sandboxHost` today. - const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options; + const { port, host = DEFAULT_BIND_HOST, allowedOrigins, publicUrl } = options; let server: Server | null = null; let sandboxUrl: string | null = null; + // Tracked separately from the URL: an advertised public URL carries the + // public port (or none at all), not the one this listener bound. + let boundPort = 0; // Defense-in-depth for the proxy page itself. Only `frame-ancestors` is set // here — fetch directives (`default-src`, `connect-src`, etc.) are @@ -175,7 +234,10 @@ export function createSandboxController( let sandboxHtml: string; try { const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); - sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); + sandboxHtml = renderSandboxProxyHtml( + readFileSync(sandboxHtmlPath, "utf-8"), + allowedOrigins, + ); } catch (e) { sandboxHtml = "Sandbox not loaded: " + @@ -186,8 +248,7 @@ export function createSandboxController( return { async start(): Promise<{ port: number; url: string }> { if (server && sandboxUrl) { - const p = parseInt(new URL(sandboxUrl).port, 10); - return { port: p, url: sandboxUrl }; + return { port: boundPort, url: sandboxUrl }; } return new Promise((resolve) => { // Guard so a `listen` error followed by a (theoretically possible) @@ -277,7 +338,24 @@ export function createSandboxController( const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" : canonicalHost; - sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`; + const derivedUrl = `http://${urlHost}:${actualPort}/sandbox`; + // An operator-supplied public URL (#1862) replaces the derived one + // only now that the listener it routes to is actually up — and only + // when that listener is on a FIXED port. A reverse proxy routes the + // public URL to one stable port; an OS-assigned port (an explicit + // `0`, a config-time collision resolved to `0`, or the EADDRINUSE + // fallback, where someone else holds the pinned port) is not it, so + // advertising the public URL would send the browser to the wrong + // listener or none. The derived URL at least works locally. + const onFixedPort = port !== 0 && !retriedDynamic; + if (publicUrl && !onFixedPort) { + console.warn( + `Sandbox: not advertising MCP_SANDBOX_FULL_ADDRESS, since the sandbox is not on a fixed ` + + `port for it to route to; advertising ${derivedUrl} instead.`, + ); + } + sandboxUrl = publicUrl && onFixedPort ? publicUrl : derivedUrl; + boundPort = actualPort; settle({ port: actualPort, url: sandboxUrl }); }); }); diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index f128685c..21b344c0 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -46,6 +46,7 @@ export async function startHonoServer( port: config.sandboxPort, host: config.sandboxHost, allowedOrigins: config.allowedOrigins, + publicUrl: config.sandboxPublicUrl, }); await sandboxController.start(); // The dedicated origin apps declaring `_meta.ui.domain` are served from @@ -54,6 +55,7 @@ export async function startHonoServer( const appOriginController = createAppOriginController({ port: config.appOriginPort, host: config.sandboxHost, + publicOrigin: config.appOriginPublicOrigin, embedderOrigins: appDocumentEmbedders( sandboxController.getUrl(), config.allowedOrigins, diff --git a/clients/web/server/vite-base-config.ts b/clients/web/server/vite-base-config.ts index 3d9c1a90..9a0a4609 100644 --- a/clients/web/server/vite-base-config.ts +++ b/clients/web/server/vite-base-config.ts @@ -55,11 +55,47 @@ export function getViteBaseConfig() { // components import it at runtime (e.g. `protocolUtils`'s // `isInputRequiredResult` / `SUBSCRIPTION_ID_META_KEY`), so listing it // here keeps the dep graph stable across story files. + // + // That was one instance of a class, not the class: any package a story + // or component starts importing after the cache was written is + // discovered the same way, and pinning each one here would chase the + // symptom. `getStorybookOptimizeDeps` below adds `force` so every run + // re-scans the story entries up front (#2340); this entry stays as the + // explicit include it always was. include: ["@modelcontextprotocol/client"], }, }; } +/** + * Storybook (browser Vitest project) optimizeDeps: the base set, re-bundled + * on every run instead of read from the cache (#2340). + * + * Vite keys that cache on the lockfile and on the config, never on what the + * story graph imports, so a cached bundle stays "valid" after a story or + * component gains an import of a package that is already installed. With a + * valid cache Vite skips the scan, and the first request for the new package + * re-runs the optimizer mid-run: the shared chunks are rewritten, the `?v=` + * browser hash is bumped, and Vite sends a `full-reload` that the Vitest + * tester iframes do not act on. The `@storybook/react` renderer already loaded + * in a live iframe still holds the old hash for its lazy + * `import("@storybook/react-dom-shim")`, so every story that iframe renders + * from then on fails with "Failed to fetch dynamically imported module" — and + * the next run is green, because the cache has caught up. `force` discards the + * cache up front so the scanner crawls every story entry before the browser + * opens: the same cold path CI takes on every PR. Cost: ~0.8s per run. + * + * Spread from `getViteBaseConfig()` rather than left to Vitest's project merge + * so the include/exclude the base set carries are the helper's own claim, and + * tested as such. + */ +export function getStorybookOptimizeDeps() { + return { + ...getViteBaseConfig().optimizeDeps, + force: true, + }; +} + /** * Dev-server optimizeDeps: rebuild from scratch each launch — no stale * pre-bundles carried across restarts (avoids 504 Outdated Optimize Dep). diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 59cee730..19f92b77 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -69,6 +69,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { port: config.sandboxPort, host: config.sandboxHost, allowedOrigins: config.allowedOrigins, + publicUrl: config.sandboxPublicUrl, }); await sandboxController.start(); // The dedicated origin apps declaring `_meta.ui.domain` are served from @@ -77,6 +78,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { const appOriginController = createAppOriginController({ port: config.appOriginPort, host: config.sandboxHost, + publicOrigin: config.appOriginPublicOrigin, embedderOrigins: appDocumentEmbedders( sandboxController.getUrl(), config.allowedOrigins, diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 5c0b23e1..1b9c4539 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -21,6 +21,12 @@ import { readInspectorVersionSafe } from "../../../core/node/version.ts"; import { resolveSandboxPort } from "./sandbox-controller.js"; import { resolveAppOriginPort } from "./app-origin-controller.js"; import { resolveBindHostname } from "./resolve-bind-host.js"; +import { + APP_ORIGIN_FULL_ADDRESS_ENV, + resolveAppOriginPublicOrigin, + resolveSandboxPublicUrl, + SANDBOX_FULL_ADDRESS_ENV, +} from "./public-address.js"; import { canonicalUrlHost, isAllInterfacesHost, @@ -74,6 +80,17 @@ export interface WebServerConfig { * address the sandbox uses. */ appOriginPort: number; + /** + * Public sandbox proxy URL advertised in place of the bind-derived one + * (`MCP_SANDBOX_FULL_ADDRESS`, #1862), or undefined when unset or refused. + * See `public-address.ts` for what is refused and why. + */ + sandboxPublicUrl?: string; + /** + * Public origin published app documents are served under + * (`MCP_APP_ORIGIN_FULL_ADDRESS`, #1862), or undefined when unset or refused. + */ + appOriginPublicOrigin?: string; logger: Logger | undefined; /** When true, open browser after server starts. */ autoOpen: boolean; @@ -460,6 +477,22 @@ export function buildWebServerConfig( } }) .filter((o): o is string => o !== null); + const allowedOrigins = configuredOrigins?.length + ? configuredOrigins + : defaultAllowedOrigins(hostname, port); + + // Resolved after the allow-list because both are checked against it: neither + // listener may share an origin with the Inspector page (#1862). The sandbox + // goes first so the app origin can be checked against it too. + const sandboxPublicUrl = resolveSandboxPublicUrl( + process.env[SANDBOX_FULL_ADDRESS_ENV], + allowedOrigins, + ); + const appOriginPublicOrigin = resolveAppOriginPublicOrigin( + process.env[APP_ORIGIN_FULL_ADDRESS_ENV], + allowedOrigins, + sandboxPublicUrl, + ); return { port, @@ -471,12 +504,12 @@ export function buildWebServerConfig( writable, initialServers, storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: configuredOrigins?.length - ? configuredOrigins - : defaultAllowedOrigins(hostname, port), + allowedOrigins, sandboxPort, sandboxHost: hostname, appOriginPort, + sandboxPublicUrl, + appOriginPublicOrigin, logger, autoOpen: resolveAutoOpen(), }; diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index ab929146..cc86b827 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -714,6 +714,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { instructions: undefined, excludedTools: [], malformedListItems: [], + connectionDiagnostics: { capturedAt: 0, outstandingRequests: [] }, appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), @@ -4295,11 +4296,18 @@ describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", ); } - // Three full modal interaction sequences against the whole App tree, so this - // one runs long enough to trip the 5s default when the suite is under load. + // Three full modal interaction sequences against the whole App tree — three + // times the work of any other test in this file, and the only one here that + // ever tripped the old 5s default under load. It keeps its own budget after + // #2323 rather than folding into the shared 15000: this is the "genuinely + // different work" exception that change establishes, and the 20000 was a + // value somebody had already chosen against an observed failure. Lowering it + // to the shared ceiling would trade a measured number for a general one and + // could reintroduce the flake (Copilot). + const THREE_MODAL_SEQUENCE_MS = 20_000; + it( "suppresses a second clear for another entry with the same URL, and allows one after it settles", - { timeout: 20000 }, async () => { // `delay: null` drops userEvent's inter-event waits, which dominate here. const user = userEvent.setup({ delay: null }); @@ -4349,5 +4357,6 @@ describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2), ); }, + THREE_MODAL_SEQUENCE_MS, ); }); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index bc62376e..62959775 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -401,6 +401,7 @@ function App() { discoverResult, excludedTools, malformedListItems, + connectionDiagnostics, lastError, } = useInspectorClient(inspectorClient); // What every settings write that landed on disk actually wrote, so a later @@ -2028,6 +2029,7 @@ function App() { transport={connectionInfoTransport} protocolEra={protocolEra} discoverResult={discoverResult} + diagnostics={connectionDiagnostics} oauth={connectionInfoOAuth} onClearOAuth={ connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index 66afa287..1dd1660e 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -1,5 +1,5 @@ import { createRef, StrictMode } from "react"; -import { act, waitFor } from "@testing-library/react"; +import { act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; @@ -79,6 +79,34 @@ async function flushAsync(): Promise { }); } +// Runs `mutate` and resolves once every MutationObserver that was already +// watching `data-mantine-color-scheme` on `documentElement` has had its +// callback delivered — which is what the AppRenderer theme push rides on. +// +// This is an awaited condition rather than a wall-clock budget, per #2384. +// A probe observer registered *after* the component's is notified after it: +// observers are queued in registration order and each delivery is an ordinary +// microtask, so by the time the probe's callback runs the component's has +// already run and the assertion below is settled. No polling and no timeout is +// involved, so nothing here is load-sensitive. +async function flipThemeAttributeAndAwaitObservers( + value: string, +): Promise { + await act(async () => { + await new Promise((resolve) => { + const probe = new MutationObserver(() => { + probe.disconnect(); + resolve(); + }); + probe.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-mantine-color-scheme"], + }); + document.documentElement.setAttribute("data-mantine-color-scheme", value); + }); + }); +} + describe("AppRenderer", () => { it("renders an iframe with the sandbox path and tool title", () => { const bridge = createMockBridge(); @@ -538,38 +566,17 @@ describe("AppRenderer", () => { // flip we trigger below. bridge.sendHostContextChange.mockClear(); - await act(async () => { - document.documentElement.setAttribute( - "data-mantine-color-scheme", - "dark", - ); - // MutationObserver callbacks are delivered on a microtask. - await Promise.resolve(); - }); - // `waitFor`, not a bare assertion after the single microtask above: how - // many ticks happy-dom takes to deliver the MutationObserver callback is - // load-dependent, so under a full parallel run the observer can still be - // pending here. Observed failing exactly once that way, with 0 calls. - // - // The default 1000ms then turned out not to be enough either — it failed - // twice in a row in `npm run local:gate` (at ~1021ms, still 0 calls) while - // passing every time this file runs alone, which is the signature of a - // budget that is too tight rather than of a broken observer. Raised - // rather than retried: a `waitFor` that is generous costs nothing on the - // passing path, since it returns as soon as the assertion holds. - await waitFor( - () => - expect(bridge.sendHostContextChange).toHaveBeenCalledWith( - expect.objectContaining({ - theme: "dark", - styles: expect.objectContaining({ - variables: expect.objectContaining({ - "--color-background-primary": "#101113", - }), - }), + await flipThemeAttributeAndAwaitObservers("dark"); + + expect(bridge.sendHostContextChange).toHaveBeenCalledWith( + expect.objectContaining({ + theme: "dark", + styles: expect.objectContaining({ + variables: expect.objectContaining({ + "--color-background-primary": "#101113", }), - ), - { timeout: 5000 }, + }), + }), ); } finally { getComputedStyleSpy.mockRestore(); @@ -590,13 +597,10 @@ describe("AppRenderer", () => { // No `initialized` emitted — the theme observer is gated, like the // container and displayMode pushes, so a pre-handshake flip is dropped. bridge.sendHostContextChange.mockClear(); - await act(async () => { - document.documentElement.setAttribute( - "data-mantine-color-scheme", - "dark", - ); - await Promise.resolve(); - }); + // Same awaited-delivery helper as the positive case: it is what makes this + // "not called" a real gate rather than a vacuous pass on a flip that was + // never delivered at all. + await flipThemeAttributeAndAwaitObservers("dark"); expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); document.documentElement.removeAttribute("data-mantine-color-scheme"); }); @@ -618,13 +622,10 @@ describe("AppRenderer", () => { }); bridge.sendHostContextChange.mockClear(); - await act(async () => { - document.documentElement.setAttribute( - "data-mantine-color-scheme", - "dark", - ); - await Promise.resolve(); - }); + // Same awaited-delivery helper as the positive case: it is what makes this + // "not called" a real gate rather than a vacuous pass on a flip that was + // never delivered at all. + await flipThemeAttributeAndAwaitObservers("dark"); expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); document.documentElement.removeAttribute("data-mantine-color-scheme"); }); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index b14cd1d6..4965d15b 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -245,12 +245,7 @@ export function AppRenderer({ if (pendingResultRef.current !== null) { const result = pendingResultRef.current; pendingResultRef.current = null; - // ext-apps' AppBridge peers on SDK v1's CallToolResult (whose - // `structuredContent` is typed narrower than v2's). Runtime-compatible; - // cast at this boundary. TODO: drop when ext-apps#702 ships a v2 peer. - void bridge.sendToolResult( - result as Parameters[0], - ); + void bridge.sendToolResult(result); } }, []); diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts index 8a111e8f..29e07b8f 100644 --- a/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts @@ -38,7 +38,7 @@ function initializeFrame( describe("appCapabilities (#1854)", () => { it("records the raw ui/initialize capabilities the bridge's schema strips", () => { - // The whole reason this module exists: ext-apps 1.7.5 parses away the + // The whole reason this module exists: ext-apps 2.0.0 parses away the // `elicitation` key, so an app that DID advertise it reads as one that did // not — and every negotiated elicitation silently becomes a fallback. const bridge = makeBridge({}); @@ -146,7 +146,7 @@ describe("appCapabilities (#1854)", () => { }); it("takes the latest accepted handshake, in both directions", () => { - // Verified against ext-apps 1.7.5: a second `ui/initialize` is accepted — + // Verified against ext-apps 2.0.0: a second `ui/initialize` is accepted — // the bridge warns about the double-mount and the latest appInfo and // capabilities REPLACE the previous ones. Freezing this at the first frame // would leave the gate reporting capabilities the bridge no longer holds. diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts index 9dbac057..2585026a 100644 --- a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts @@ -5,7 +5,7 @@ import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; * The app capabilities exactly as the view sent them, per bridge. * * `AppBridge.getAppCapabilities()` cannot be used for `elicitation` (#1854): - * ext-apps 1.7.5 parses the view's `ui/initialize` params through + * ext-apps 2.0.0 parses the view's `ui/initialize` params through * `McpUiAppCapabilitiesSchema`, a plain Zod object, so any key that schema does * not declare is **stripped before the bridge stores it**. `elicitation` is * exactly such a key — it is what ext-apps#733 adds — so an app that correctly @@ -25,10 +25,10 @@ const rawAppCapabilities = new WeakMap>(); /** * The only part of a transport this needs: the inbound-message callback. * - * Structural, and generic over the message type, on purpose. ext-apps' - * `PostMessageTransport` implements the SDK *v1* `Transport` — a different - * nominal type from the v2 client's, though runtime-identical — so naming - * either would force a cast at the call site. Generic rather than + * Structural, and generic over the message type, on purpose. This helper + * touches one optional property, so naming the SDK `Transport` (which ext-apps' + * `PostMessageTransport` implements since 2.0.0) would only make every test + * fake carry `start`/`send`/`close` it never calls. Generic rather than * `(message: unknown)` because a handler typed for a narrower message is not * assignable to one typed for `unknown` (contravariance), which would put the * cast back; `rest` is `never[]` for the same reason, accepting any trailing diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 00a67983..b8a38236 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -39,9 +39,7 @@ import type { AppRenderSource, BridgeFactory } from "./AppRenderer"; function resolveSourceUri(source: AppRenderSource): string | undefined { return source.kind === "resource" ? source.resourceUri - : getToolUiResourceUri( - source.tool as Parameters[0], - ); + : getToolUiResourceUri(source.tool); } /** @@ -324,7 +322,7 @@ export function createAppBridgeFactory( // declare its own csp/permissions. const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES, - // `elicitation` is not part of ext-apps 1.7.5's `McpUiHostCapabilities` + // `elicitation` is not part of ext-apps 2.0.0's `McpUiHostCapabilities` // (ext-apps#733 adds it), so it is spread in as an extra key. The bridge // forwards the capabilities object verbatim in its `ui/initialize` // response, which is exactly what the app reads. TODO: drop the cast when @@ -333,17 +331,9 @@ export function createAppBridgeFactory( ? ({ elicitation: {} } as Partial) : {}), }; - // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both - // are runtime-compatible with v2's. Cast at this single construction - // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. - const bridge = new AppBridge( - client as unknown as ConstructorParameters[0], - HOST_INFO as unknown as ConstructorParameters[1], - hostCapabilities, - { - hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), - }, - ); + const bridge = new AppBridge(client, HOST_INFO, hostCapabilities, { + hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), + }); // The double-iframe proxy posts `sandboxready` once it can receive content. // Read the tool's UI resource and hand its HTML (plus any sandbox/permission diff --git a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts index e7907926..dd756846 100644 --- a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts +++ b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts @@ -20,8 +20,9 @@ export const APP_ELICITATION_TIMEOUT_MS = 10 * 60 * 1000; * * This is ext-apps' own `AppBridge.requestElicitation` from * modelcontextprotocol/ext-apps#733 — same method, same params, same result — - * implemented against the bridge's generic `request()` because the released - * package (1.7.5) predates that PR. Replace the body with a call to + * implemented against the bridge's generic `request()` because #733 is not + * yet in a published release (2.0.0 does not include it). Replace the body + * with a call to * `bridge.requestElicitation(params)` once a release containing #733 ships; * nothing on the wire changes when that happens. * @@ -37,23 +38,20 @@ export async function requestAppElicitation( // Fail closed on the app's own advertisement rather than discovering it as a // "-32601 method not found" ten minutes later: an app that never registered // an elicitation handler is a fallback case, not an error case. - // NOT `bridge.getAppCapabilities()` directly: ext-apps 1.7.5 strips the + // NOT `bridge.getAppCapabilities()` directly: ext-apps 2.0.0 strips the // `elicitation` key when it parses `ui/initialize`. See appCapabilities.ts. if (!appAdvertisesElicitation(bridge)) { throw new Error("App does not support elicitation"); } - // ext-apps 1.7.5's send union (`AppRequest`) has no `ElicitRequest` member — - // that is precisely what #733 adds — so TypeScript sees no overlap with the - // existing members and a single `as` is rejected. The double cast is the - // documented-gap case: the runtime is a plain JSON-RPC send of the standard - // method with its standard params, verified against the app-side handler in - // the fixture and the bridge tests. Confined to this one line and removed - // with the ext-apps bump, when `bridge.requestElicitation(params)` replaces it. - const request = { - method: "elicitation/create", - params, - } as unknown as Parameters[0]; - return (await bridge.request(request, ElicitResultSchema, { + // Since ext-apps 2.0.0 `AppBridge` extends the SDK v2 `Protocol`, whose + // `request()` takes the standard `Request` shape — `ElicitRequest` is one — + // so the send needs no cast (the 1.x peer's `AppRequest` union had no + // `ElicitRequest` member, which is what forced a double cast here; #1745). + // The runtime is a plain JSON-RPC send of the standard method with its + // standard params, verified against the app-side handler in the fixture and + // the bridge tests. + const request: ElicitRequest = { method: "elicitation/create", params }; + return await bridge.request(request, ElicitResultSchema, { timeout: timeoutMs, - })) as ElicitResult; + }); } diff --git a/clients/web/src/components/elements/EraBadge/EraBadge.tsx b/clients/web/src/components/elements/EraBadge/EraBadge.tsx index 37c4ebe4..d3caa1d0 100644 --- a/clients/web/src/components/elements/EraBadge/EraBadge.tsx +++ b/clients/web/src/components/elements/EraBadge/EraBadge.tsx @@ -5,14 +5,22 @@ import { formatEra, isModernEra } from "./eraUtils"; export interface EraBadgeProps { /** The negotiated protocol era; `undefined` renders as Legacy. */ era: ProtocolEra | undefined; + /** + * Font weight override. The app-wide `ThemeBadge` defaults to `fw: 600`, + * which is right where this badge is a standalone chip (ProtocolListPanel's + * header). Connection Info renders it as the *value* half of a label/value + * row, where the convention is bold label / normal value, so it passes 400. + * Left undefined the theme default applies (#2328). + */ + fw?: number; } // Labels a connection's negotiated protocol era (SEP §7.8). Feed it from // connection state only — see the note in `eraUtils` on why the era must never // be inferred from individual message frames. -export function EraBadge({ era }: EraBadgeProps) { +export function EraBadge({ era, fw }: EraBadgeProps) { return ( - + {formatEra(era)} ); diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 9f5c92ce..0c472f8a 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -312,6 +312,60 @@ describe("ClientSettingsForm EMA IdP session", () => { expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument(); }); + it("tells the reader the loopback exemption exists, in both pieces of CIMD copy", () => { + renderWithMantine( + , + ); + + // The copy is part of this feature's contract, not decoration: the whole + // point of #2305 is that a local fixture URL is enterable, and an + // HTTPS-only instruction here puts that back out of reach for a reader + // even while the validator stays permissive. Both places that state the + // requirement are asserted, since either one alone would still mislead. + const hint = screen.getByText(/must be served over HTTPS/); + expect(hint).toHaveTextContent("localhost, 127.0.0.1 or [::1]"); + + expect( + screen.getByText(/HTTPS URL of your OAuth client metadata JSON document/), + ).toHaveTextContent( + "http:// is accepted only on localhost, 127.0.0.1 or [::1]", + ); + }); + + it("shows no CIMD URL error for a loopback http URL", () => { + renderWithMantine( + , + ); + + expect( + screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR), + ).not.toBeInTheDocument(); + }); + it("shows no CIMD URL error for a valid URL", () => { renderWithMantine( - The metadata document must be served over HTTPS and list - this redirect URI:{" "} + The metadata document must be served over HTTPS — or over + plain HTTP from localhost, 127.0.0.1 or [::1], for a local + fixture — and list this redirect URI:{" "} {typeof window !== "undefined" ? `${window.location.origin}/oauth/callback` : "http://localhost:6274/oauth/callback"} patch({ clientMetadataUrl: e.currentTarget.value }) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 50549076..8e63e5a3 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -46,6 +46,51 @@ export const FullCapabilities: Story = { }, }; +// The snapshot clock, read once when the stories load. The rows start from it +// and then tick against the wall clock, so the fixture has to be "now" — a +// fixed historical date would read as months ago a second after mounting. +const STORY_NOW = Date.now(); + +/** + * The Connection Activity section, in the #2187 shape: the notification + * stream has been open the whole session and delivered nothing, `tools/list` + * has been outstanding for a minute, and nothing has been answered since + * `initialize`. The same facts a request timeout's message reports, shown + * while the request is still in flight (#2318). + */ +export const WithConnectionActivity: Story = { + args: { + initializeResult: fullResult, + clientCapabilities: fullClientCaps, + transport: "streamable-http", + diagnostics: { + capturedAt: STORY_NOW, + outstandingRequests: [ + { id: 2, method: "tools/list", sentAt: STORY_NOW - 60_000 }, + { id: 3, method: "ping", sentAt: STORY_NOW - 12_000 }, + ], + lastResponse: { method: "initialize", receivedAt: STORY_NOW - 61_000 }, + notificationStream: { + url: "http://127.0.0.1:9779/mcp", + openedAt: STORY_NOW - 252_000, + eventCount: 0, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Connection Activity")).toBeInTheDocument(); + // Ticking: the rows may have advanced a second or two since the fixture + // was stamped, so match the shape rather than the exact second. + await expect( + canvas.getByText(/^tools\/list — sent 1m0\ds ago$/), + ).toBeInTheDocument(); + await expect( + canvas.getByText(/^GET \/mcp — open for 4m1\ds, 0 events delivered$/), + ).toBeInTheDocument(); + }, +}; + export const ModernEra: Story = { args: { initializeResult: { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index d181c36a..00d6b61d 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -5,6 +5,13 @@ import type { InitializeResult, } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js"; +import { + NO_NOTIFICATION_STREAM_LABEL, + NO_OUTSTANDING_REQUESTS_LABEL, + NO_RESPONSE_YET_LABEL, + NO_STREAM_ON_STDIO_LABEL, +} from "../../../utils/connectionActivity"; import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, ConnectionInfoContent, @@ -567,6 +574,168 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("token-123")).toBeInTheDocument(); }); + it("weights labels bold and values normal, in both halves of the modal", () => { + renderWithMantine( + , + ); + + // The convention is the whole point of #2328: the label is the fixed + // scaffolding a reader scans down, the value is what differs. Asserted in + // Server Implementation *and* OAuth Details, because the defect being + // guarded against is the two halves disagreeing — checking one alone would + // pass on a modal that is internally inconsistent. + for (const label of ["Name", "Protocol", "Client ID", "Scopes"]) { + expect(screen.getAllByText(label)[0]).toHaveStyle({ fontWeight: "600" }); + } + // `getByText`, not `queryByText` + `?? ""`: an absent value would make the + // optional form pass vacuously (undefined → "" → not "600"), so the test + // would go green on a row that had stopped rendering at all. + for (const value of ["Everything Server", "read"]) { + expect(screen.getByText(value).style.fontWeight).not.toBe("600"); + } + + // Badge values count too. `ThemeBadge` defaults to `fw: 600`, so Status, + // Transport and Era read bold-label/bold-value unless overridden — the + // whole-modal claim is false without this. + for (const badge of ["streamable-http", "Legacy", "Authorized"]) { + // `getByText` lands on the Badge's inner label span; `fw` is applied to + // the root, so walk up to it or the assertion reads an empty string and + // passes against anything. + const root = screen.getByText(badge).closest('[class*="Badge-root"]'); + expect((root as HTMLElement | null)?.style.fontWeight).toBe("400"); + } + }); + + it("gives Client ID and Auth URL the full width, with no inset surface", () => { + renderWithMantine( + , + ); + + // A CIMD client id IS a URL, so it is long by construction. In the + // two-column grid it got half the modal and broke mid-token + // (`…/client-metadata.` / `json`), which reads as a rendering fault rather + // than as one value. + for (const value of [ + "http://127.0.0.1:8093/client-metadata.json?profile=long-enough-to-wrap", + "https://auth.example.com/authorize", + ]) { + expect(screen.getByText(value)).toHaveStyle({ + backgroundColor: "transparent", + }); + } + + // The background alone does not pin the *layout*: swapping FullWidthField + // back for the two-column SimpleGrid would keep it transparent and still + // pass. Assert the structure that makes the value full width — label and + // value are siblings in a column, and neither sits in a SimpleGrid. + for (const [label, value] of [ + [ + "Client ID", + "http://127.0.0.1:8093/client-metadata.json?profile=long-enough-to-wrap", + ], + ["Auth URL", "https://auth.example.com/authorize"], + ]) { + const labelNode = screen.getByText(label); + const valueNode = screen.getByText(value); + expect(valueNode.parentElement).toBe(labelNode.parentElement); + expect(valueNode.closest('[class*="SimpleGrid"]')).toBeNull(); + } + }); + + it("bolds the token captions, which are field labels in the same list", () => { + renderWithMantine( + , + ); + + // OAuthTokenField renders its own caption, so the weight convention has to + // be asserted through it — the other weight test renders no token at all. + for (const caption of ["Access Token", "ID Token"]) { + expect(screen.getByText(caption).style.fontWeight).toBe("600"); + } + }); + + it("groups the full-width fields at the end, Client ID directly above Access Token", () => { + renderWithMantine( + , + ); + + // Order is the assertion, so read the labels off the DOM rather than + // checking each is merely present: the inline two-column rows come first, + // then every label-over-value field together. Interleaving them is what + // this guards against — it broke the scan down the label column, and the + // tokens already used the full-width layout at the bottom. + const order = [ + "Client registration", + "Scopes", + "Auth URL", + "Client ID", + "Access Token", + ]; + const positions = order.map((label) => { + const node = screen.getAllByText(label)[0]; + expect(node).toBeInTheDocument(); + return ( + node.compareDocumentPosition(screen.getAllByText("Protocol")[0]) & + Node.DOCUMENT_POSITION_PRECEDING + ); + }); + expect(positions.every(Boolean)).toBe(true); + + const labelNode = (label: string) => screen.getAllByText(label)[0]; + for (let i = 0; i < order.length - 1; i++) { + const earlier = labelNode(order[i]); + const later = labelNode(order[i + 1]); + expect( + earlier.compareDocumentPosition(later) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + }); + it("renders EMA idp session when provided", () => { renderWithMantine( { expect(onClearOAuth).toHaveBeenCalledTimes(1); }); }); + +describe("ConnectionInfoContent connection activity (#2318)", () => { + const NOW = 1_000_000; + const seconds = (n: number) => n * 1000; + + it("omits the section when no diagnostics are supplied", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Connection Activity")).not.toBeInTheDocument(); + }); + + it("renders the idle state: nothing unanswered, nothing received, stream not opened", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Connection Activity")).toBeInTheDocument(); + expect(screen.getByText(NO_OUTSTANDING_REQUESTS_LABEL)).toBeInTheDocument(); + expect(screen.getByText(NO_RESPONSE_YET_LABEL)).toBeInTheDocument(); + expect(screen.getByText(NO_NOTIFICATION_STREAM_LABEL)).toBeInTheDocument(); + }); + + it("lists each unanswered request on its own line, with the last response and the open stream", () => { + const diagnostics: ConnectionDiagnostics = { + capturedAt: NOW, + outstandingRequests: [ + { id: 2, method: "tools/list", sentAt: NOW - seconds(60) }, + { id: 3, method: "ping", sentAt: NOW - seconds(12) }, + ], + lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) }, + notificationStream: { + url: "http://127.0.0.1:9779/mcp", + openedAt: NOW - seconds(252), + eventCount: 0, + }, + }; + renderWithMantine( + , + ); + const outstanding = screen.getByTestId("connection-activity-outstanding"); + expect(outstanding.children).toHaveLength(2); + expect(outstanding.children[0]?.textContent).toBe( + "tools/list — sent 1m00s ago", + ); + expect(outstanding.children[1]?.textContent).toBe("ping — sent 12s ago"); + expect(screen.getByText("initialize — 1m01s ago")).toBeInTheDocument(); + expect( + screen.getByText("GET /mcp — open for 4m12s, 0 events delivered"), + ).toBeInTheDocument(); + }); + + it("reports the stream row as not applicable on stdio", () => { + renderWithMantine( + , + ); + // Both the Session row and the Notification stream row read N/A (stdio). + expect(screen.getAllByText(NO_STREAM_ON_STDIO_LABEL)).toHaveLength(2); + }); +}); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index a9f567dc..6c604880 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -18,6 +18,14 @@ import type { ServerCapabilities, } from "@modelcontextprotocol/client"; import type { ServerType } from "@inspector/core/mcp/types.js"; +import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js"; +import { + formatLastResponse, + formatNotificationStream, + formatOutstandingRequests, + NO_OUTSTANDING_REQUESTS_LABEL, +} from "../../../utils/connectionActivity"; +import { useTickingClock } from "../../../hooks/useTickingClock"; import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import { getSkillsExtension } from "@inspector/core/mcp/skills.js"; import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js"; @@ -75,15 +83,40 @@ export interface ConnectionInfoContentProps { * capabilities, and extensions learned up front. Undefined on legacy. (#1626) */ discoverResult?: DiscoverResult; + /** + * What the client is still waiting on, when it last heard back, and the + * state of the notification stream (#2318). Renders the Connection Activity + * section when present — the same facts a request timeout reports, shown + * here while the request is still in flight. Durations first read against + * the snapshot's own `capturedAt` and then tick once a second while the + * panel is open (`useTickingClock`), so an in-flight request's age keeps + * moving rather than freezing at the moment it went out. + */ + diagnostics?: ConnectionDiagnostics; oauth?: OAuthDetails; onClearOAuth?: () => void; } -const ValueText = Text.withProps({ +// 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 +// answer, so nothing stood out and the two columns fought each other. +const FieldLabel = Text.withProps({ size: "sm", fw: 600, }); +const ValueText = Text.withProps({ + size: "sm", +}); + +// A badge standing in as the *value* half of a label/value row. The app-wide +// `ThemeBadge` defaults to `fw: 600`, which is right for a standalone chip but +// makes these rows read bold-label/bold-value — the one convention this modal +// is not supposed to have. The chip still reads as a chip: its emphasis comes +// from the outline and colour, not the font weight (#2328). +const ValueBadge = Badge.withProps({ variant: "outline", fw: 400 }); + // Shown for Name/Version when the server didn't report `serverInfo` — an em dash // plus an explicit note so the client-side catalog fallback is never mistaken // for a value the server sent. Exported so tests/stories assert against it @@ -104,7 +137,14 @@ const SectionHeading = Title.withProps({ // `Code` block — that keeps the whole value visible and removes a scroll region // that would otherwise need its own keyboard access (axe // `scrollable-region-focusable`). -const ValueCode = Code.withProps({ variant: "wrapping" }); +const ValueCode = Code.withProps({ variant: "wrapping-plain" }); + +// A long OAuth value (client id, auth URL) gets its label on its own line and +// the value across the full modal width beneath it, the way `OAuthTokenField` +// already lays out a token. In the two-column grid these values had roughly +// half the width and wrapped mid-token — `…/client-metadata.` / `json` — which +// reads as a rendering fault rather than as one URL (#2328). +const FullWidthField = Stack.withProps({ gap: 4 }); // One declared sub-option of an extension. Mirrors `CapabilityItem`'s ✓/✗ row // rather than reusing it: that element's `capability` prop is the closed union @@ -269,6 +309,7 @@ export function ConnectionInfoContent({ transport, protocolEra, discoverResult, + diagnostics, oauth, onClearOAuth, }: ConnectionInfoContentProps) { @@ -295,6 +336,15 @@ export function ConnectionInfoContent({ ? serverInfo.version?.trim() || "—" : SERVER_INFO_NOT_REPORTED_LABEL; + // The activity rows' clock: the snapshot's own on first paint (pure), then + // the wall clock once a second so "sent 5s ago" keeps counting. No timer + // at all when there is no activity section to tick. + const now = useTickingClock( + diagnostics?.capturedAt ?? 0, + 1000, + diagnostics !== undefined, + ); + const serverCaps = getServerCapabilityEntries(capabilities, protocolEra); const clientCaps = getCapabilityEntries( clientCapabilities, @@ -306,31 +356,67 @@ export function ConnectionInfoContent({ Server Implementation - Name + Name {displayName} - Version + Version {displayVersion} - Protocol + Protocol {protocolVersion || "—"} - Transport - {transport} + Transport + {transport} - Era - + Era + - Session + Session {formatSession(protocolEra, transport)} + {diagnostics && ( + + Connection Activity + + Unanswered requests + {/* A `Stack` so several in-flight requests read as a list, the + way the extension sections do; one line per request. */} + + {diagnostics.outstandingRequests.length === 0 ? ( + {NO_OUTSTANDING_REQUESTS_LABEL} + ) : ( + formatOutstandingRequests(diagnostics, now).map( + (line, index) => ( + + {line} + + ), + ) + )} + + + Last response + {formatLastResponse(diagnostics, now)} + + Notification stream + + {formatNotificationStream( + diagnostics.notificationStream, + transport, + now, + )} + + + + )} + {discoverResult && ( Discovery - Supported versions + Supported versions {discoverResult.supportedVersions.length > 0 ? discoverResult.supportedVersions.join(", ") @@ -433,26 +519,17 @@ export function ConnectionInfoContent({ OAuth Details - Protocol + Protocol {formatProtocol(oauth.protocol)} - Status - + Status + {oauth.authorized ? "Authorized" : "Not authorized"} - + - {oauth.clientId && ( - - Client ID - {oauth.clientId} - - )} {oauth.clientRegistrationKind && ( - Client registration + Client registration {formatClientRegistrationKind(oauth.clientRegistrationKind)} @@ -460,22 +537,34 @@ export function ConnectionInfoContent({ )} {oauth.protocol === "ema" && oauth.idpSession && ( - IdP session + IdP session {formatIdpSession(oauth.idpSession)} )} - {oauth.authUrl && ( - - Auth URL - {oauth.authUrl} - - )} {oauth.scopes && oauth.scopes.length > 0 && ( - Scopes + Scopes {formatScopes(oauth.scopes)} )} + {/* The full-width fields are kept together at the end of the + section, directly above the token rows, which use the same + label-over-value layout. Interleaved with the inline two-column + rows they broke the scan down the label column for every row + after them, and Client ID — the one most likely to be long — + was the worst offender (#2328). */} + {oauth.authUrl && ( + + Auth URL + {oauth.authUrl} + + )} + {oauth.clientId && ( + + Client ID + {oauth.clientId} + + )} {oauth.accessToken && ( { expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument(); }); + it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Connection Activity")).toBeInTheDocument(); + expect(screen.getByText("tools/list — sent 1m00s ago")).toBeInTheDocument(); + }); + it("forwards oauth details to ConnectionInfoContent", () => { renderWithMantine( void; } @@ -51,6 +54,7 @@ export function ConnectionInfoModal({ transport, protocolEra, discoverResult, + diagnostics, oauth, onClearOAuth, }: ConnectionInfoModalProps) { @@ -79,6 +83,7 @@ export function ConnectionInfoModal({ transport={transport} protocolEra={protocolEra} discoverResult={discoverResult} + diagnostics={diagnostics} oauth={oauth} onClearOAuth={onClearOAuth} /> diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx index 21349a41..725702d2 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -86,6 +86,31 @@ const streamingEntry: FetchRequestEntry = { category: "transport", }; +/** + * The standalone `GET /mcp` notification stream, still open, as the tracker + * watches it: the body is never captured, so the placeholder reports what the + * stream has delivered instead (#2318). + */ +const notificationStreamEntry: FetchRequestEntry = { + id: "n-notify", + timestamp: new Date("2026-03-17T10:30:01Z"), + method: "GET", + url: "http://localhost:3000/mcp", + requestHeaders: { + accept: "text/event-stream", + "mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb", + }, + responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { + "cache-control": "no-cache", + "content-type": "text/event-stream", + }, + duration: 12, + stream: { eventCount: 0 }, + category: "transport", +}; + const transportError: FetchRequestEntry = { id: "n-4", timestamp: new Date("2026-03-17T10:30:15Z"), @@ -166,6 +191,18 @@ export const StreamingResponse: Story = { args: { entry: streamingEntry, isListExpanded: true }, }; +export const NotificationStreamOpen: Story = { + args: { entry: notificationStreamEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + "Long-lived stream — 0 events delivered, still open; body not captured", + ), + ).toBeInTheDocument(); + }, +}; + export const FetchError: Story = { args: { entry: transportError, isListExpanded: true }, }; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index e42d841c..e5fa1e70 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -150,6 +150,24 @@ describe("NetworkEntry", () => { ).toBeInTheDocument(); }); + it("shows the watched stream's event count and open state in place of the body (#2318)", async () => { + const user = userEvent.setup(); + const open: FetchRequestEntry = { + ...baseEntry, + method: "GET", + responseHeaders: { "content-type": "text/event-stream" }, + responseBody: undefined, + stream: { eventCount: 3 }, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect( + screen.getByText( + "Long-lived stream — 3 events delivered, still open; body not captured", + ), + ).toBeInTheDocument(); + }); + it("shows an SSE badge on long-lived GET event-stream entries", () => { const sse: FetchRequestEntry = { ...baseEntry, diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 8aecb0fc..b19fe362 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -15,7 +15,11 @@ import { } from "@mantine/core"; import { RiErrorWarningLine } from "react-icons/ri"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; -import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; +import { + isLongLivedStreamEntry, + longLivedStreamLabel, + uncapturedBodyNote, +} from "../../../utils/uncapturedBodyNote"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; @@ -158,13 +162,6 @@ function statusLabel(entry: FetchRequestEntry): string { : `${entry.responseStatus}`; } -function isLongLivedStream(entry: FetchRequestEntry): boolean { - return isLongLivedStreamResponse( - entry.method, - entry.responseHeaders?.["content-type"], - ); -} - // Header-table cell text. A modern MCP-mirrored header name gets a violet accent // so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version) // stand out from ordinary ones; a value that disagrees with the request body is @@ -217,6 +214,17 @@ const SentinelTooltip = Tooltip.withProps({ w: 280, }); +// The long-lived-stream marker on a GET entry, on the `status` variant like +// the status chip beside it. Amber cannot clear WCAG AA as either a filled +// (white on orange, 3.04:1) or a light (orange on tint, 3.03:1) badge at this +// size; `ThemeBadge` pins `status` + orange to a mid amber fill with black +// text for exactly that reason, which is what the a11y gate needs the moment a +// story renders one (#2318 added the first). +const StreamBadge = Badge.withProps({ + color: "orange", + variant: "status", +}); + const Base64Badge = Badge.withProps({ size: "xs", color: "gray", @@ -542,7 +550,9 @@ export function NetworkEntry({ {entry.duration != null && ( {formatDuration(entry.duration)} )} - {isLongLivedStream(entry) && SSE} + {isLongLivedStreamEntry(entry) && ( + {longLivedStreamLabel(entry)} + )} {statusLabel(entry)} @@ -647,11 +657,7 @@ export function NetworkEntry({ label="Response body" /> ) : ( - - {isLongLivedStream(entry) - ? "Long-lived stream — body not captured" - : "(empty)"} - + {uncapturedBodyNote(entry)} )} )} diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx index c6e6fb69..245feb66 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx @@ -130,6 +130,12 @@ function InteractiveRender(args: ServerSettingsFormProps) { settings: { ...args.settings, maxFetchRequests: value }, }); }} + onSkillCatalogLimitChange={(field, value) => { + args.onSkillCatalogLimitChange(field, value); + updateArgs({ + settings: { ...args.settings, [field]: value }, + }); + }} onProtocolEraChange={(value) => { args.onProtocolEraChange(value); updateArgs({ @@ -178,6 +184,7 @@ const meta: Meta = { onPaginatedListsChange: fn(), onAdvertisedExtensionChange: fn(), onMaxFetchRequestsChange: fn(), + onSkillCatalogLimitChange: fn(), onProtocolEraChange: fn(), onModernLogLevelChange: fn(), onOAuthChange: fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 081b5cc2..26488227 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -70,6 +70,7 @@ const baseHandlers = { onPaginatedListsChange: vi.fn(), onAdvertisedExtensionChange: vi.fn(), onMaxFetchRequestsChange: vi.fn(), + onSkillCatalogLimitChange: vi.fn(), onProtocolEraChange: vi.fn(), onModernLogLevelChange: vi.fn(), onOAuthChange: vi.fn(), @@ -559,6 +560,96 @@ describe("ServerSettingsForm", () => { expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500); }); + describe("Skills section (#2294)", () => { + it("renders the default catalog budget when the server sets none", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue( + "256", + ); + expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue( + "67,108,864 bytes", + ); + }); + + it("renders configured limits", () => { + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue( + "10", + ); + expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue( + "2,048 bytes", + ); + }); + + it("emits the typed number for each field", async () => { + const user = userEvent.setup(); + const onSkillCatalogLimitChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Maximum Number of Skills/), "2"); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxSkills", + 12, + ); + await user.type(screen.getByLabelText(/Maximum Catalog Size/), "5"); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxBytes", + 15, + ); + }); + + it("keeps the current limit when a field is cleared", async () => { + // A cleared budget is not a usable setting, so it must not persist as 0. + const user = userEvent.setup(); + const onSkillCatalogLimitChange = vi.fn(); + renderWithMantine( + , + ); + await user.clear(screen.getByLabelText(/Maximum Number of Skills/)); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxSkills", + 256, + ); + await user.clear(screen.getByLabelText(/Maximum Catalog Size/)); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxBytes", + 4096, + ); + }); + }); + describe("stdio Working Directory (Options) / Environment Variables section", () => { it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => { renderWithMantine( diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 0ac753f9..2ad4c5ae 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -38,6 +38,10 @@ import { } from "@inspector/core/auth/authorizationParams.js"; import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; +import { + isSkillCatalogLimit, + resolveSkillCatalogBudget, +} from "@inspector/core/mcp/skills.js"; import type { Root } from "@modelcontextprotocol/client"; /** @@ -60,9 +64,15 @@ export type ServerSettingsSection = | "headers" | "metadata" | "timeouts" + | "skills" | "oauth" | "roots"; +/** The two per-server skills verification budget fields (#2294). */ +export type SkillCatalogLimitField = + | "skillCatalogMaxSkills" + | "skillCatalogMaxBytes"; + export interface ServerSettingsFormProps { settings: InspectorServerSettings; /** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */ @@ -101,6 +111,14 @@ export interface ServerSettingsFormProps { */ onAdvertisedExtensionChange: (key: string, checked: boolean) => void; onMaxFetchRequestsChange: (value: number) => void; + /** + * Set one skills verification budget limit. Only ever called with a positive + * integer: a cleared or invalid entry re-emits the current value (#2294). + */ + onSkillCatalogLimitChange: ( + field: SkillCatalogLimitField, + value: number, + ) => void; onProtocolEraChange: (value: ServerProtocolEra) => void; onModernLogLevelChange: (value: ModernLogLevel) => void; /** @@ -166,6 +184,22 @@ const TimeoutInput = NumberInput.withProps({ suffix: " ms", }); +// Positive integers only — `0` is not "unlimited" for a verification budget. +const SkillCountInput = NumberInput.withProps({ + min: 1, + allowDecimal: false, + allowNegative: false, + thousandSeparator: ",", +}); + +const SkillBytesInput = NumberInput.withProps({ + min: 1, + allowDecimal: false, + allowNegative: false, + thousandSeparator: ",", + suffix: " bytes", +}); + const ExtensionsHint = Text.withProps({ size: "xs", c: "var(--inspector-text-secondary)", @@ -447,6 +481,7 @@ export function ServerSettingsForm({ onPaginatedListsChange, onAdvertisedExtensionChange, onMaxFetchRequestsChange, + onSkillCatalogLimitChange, onProtocolEraChange, onModernLogLevelChange, negotiatedEra, @@ -496,6 +531,20 @@ export function ServerSettingsForm({ Number.isNaN(parsed) ? settings.maxFetchRequests : parsed, ); }; + // Absent settings render as the defaults `verifySkills` will actually apply. + const skillCatalogBudget = resolveSkillCatalogBudget(settings); + const handleSkillCatalogLimitChange = + (field: SkillCatalogLimitField, current: number) => + (value: number | string) => { + // Mantine emits "" on a clear. Like the Network Log Size field, keep the + // current value rather than persisting something `verifySkills` would + // discard — a cleared or zero budget is not a meaningful setting here. + const parsed = typeof value === "number" ? value : parseInt(value, 10); + onSkillCatalogLimitChange( + field, + isSkillCatalogLimit(parsed) ? parsed : current, + ); + }; const handleTimeoutChange = (field: "connectionTimeout" | "requestTimeout" | "taskTtl") => (value: number | string) => { @@ -816,6 +865,32 @@ export function ServerSettingsForm({ + + Skills + + + + + + + + Roots diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 0d704e42..d35dedea 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -679,6 +679,27 @@ describe("ServerSettingsModal", () => { expect(typeof call.maxFetchRequests).toBe("number"); }); + it("calls onSettingsChange when changing a skills catalog limit (#2294)", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Skills" })); + await user.type(screen.getByLabelText(/Maximum Number of Skills/), "0"); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + ...emptySettings, + skillCatalogMaxSkills: 30, + }); + }); + it("preserves sibling rows when editing one of several headers", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index b8a422c0..db2b63a4 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -17,6 +17,7 @@ import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info import { ServerSettingsForm, type ServerSettingsSection, + type SkillCatalogLimitField, } from "../ServerSettingsForm/ServerSettingsForm"; // The "environment" section only renders for stdio servers and the "oauth" @@ -34,6 +35,7 @@ function allSectionsFor( "headers", "metadata", "timeouts", + "skills", ...(isOAuthCapableServerType(serverType) ? (["oauth"] as const) : []), "roots", ]; @@ -226,6 +228,13 @@ export function ServerSettingsModal({ onSettingsChange({ ...settings, maxFetchRequests: value }); } + function handleSkillCatalogLimitChange( + field: SkillCatalogLimitField, + value: number, + ) { + onSettingsChange({ ...settings, [field]: value }); + } + function handleProtocolEraChange(value: ServerProtocolEra) { onSettingsChange({ ...settings, protocolEra: value }); } @@ -299,6 +308,7 @@ export function ServerSettingsModal({ onPaginatedListsChange={handlePaginatedListsChange} onAdvertisedExtensionChange={handleAdvertisedExtensionChange} onMaxFetchRequestsChange={handleMaxFetchRequestsChange} + onSkillCatalogLimitChange={handleSkillCatalogLimitChange} onProtocolEraChange={handleProtocolEraChange} onModernLogLevelChange={handleModernLogLevelChange} negotiatedEra={negotiatedEra} diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx index 133c8617..44b5c409 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { act, + RAF_SLACK_MS, renderWithMantine, renderWithMantineTransitions, screen, @@ -18,7 +19,18 @@ import { HEADER_ANIM_MS, ViewHeader } from "./ViewHeader"; // duration plus rAF scheduling slack, so bumping HEADER_ANIM_MS can't silently // make the settle insufficient. (This is why ViewHeader.tsx exports // HEADER_ANIM_MS — the settle window must track the animation it settles.) -const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200; +// +// The slack half is `RAF_SLACK_MS` rather than a bare `+ 200` (#2323): the two +// terms answer to different things — the first to the component, the second to +// how busy the machine is — and only the second should move when the machine +// gets busier. Naming it is what lets it move in one place, here and in the +// helper's own fallback. +// +// No `enterDelay`/`exitDelay` is passed to any of ViewHeader's four +// `Transition`s, so HEADER_ANIM_MS really is the longest JS chain. The 150ms +// stagger visible in the UI is a CSS `animation-delay` (`App.css`), and a CSS +// animation schedules no JS timer for the settle to drain. +const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + RAF_SLACK_MS; // Mock @mantine/hooks so we can control useMediaQuery results per test. const mediaQueryMock = vi.hoisted(() => ({ value: false })); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx index dfdc4581..cfb08b60 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx @@ -440,6 +440,45 @@ export const LongSkillDocument: Story = { const geometry = () => sections().map((s) => Math.round(s.getBoundingClientRect().height)); + // Hold the first geometry sample until the Frontmatter section has settled + // on its final content. That section renders its YAML through + // `CodeHighlight`, which paints a plain `Code` block until the lazily + // imported Prism runtime and grammar land and then swaps the highlighter + // in — for this fixture a rendering 23px taller (the theme's `
`
+    // padding against Mantine's). Whether the swap has happened by the time
+    // the story gets here depends on how long those dynamic imports take: on
+    // a quiet machine an earlier story in this file has already loaded them,
+    // and on a loaded one they land between the two samples below, so
+    // `before` measured the placeholder and the reopen measured the
+    // highlighter — `[…, 155, 285]` against `[…, 178, 262]`, one total
+    // redistributed (#2350). Neither sample was mid-layout; the section's
+    // content changed under the story. Waiting for the highlighted output
+    // makes both samples measure the same content, which is what the
+    // collapse-then-reopen comparison at the end assumes.
+    const frontmatterPanel = (
+      await canvas.findByRole("button", { name: /Frontmatter/ })
+    )
+      .closest(".mantine-Accordion-item")
+      ?.querySelector(".mantine-Accordion-panel");
+    if (!(frontmatterPanel instanceof HTMLElement)) {
+      throw new Error("Frontmatter panel not found");
+    }
+    await waitFor(
+      async () => {
+        // `
` is the highlighter's output; the placeholder is a
+        // Mantine `Code` block, a bare `
`.
+        await expect(frontmatterPanel.querySelector("pre code")).not.toBeNull();
+      },
+      // What this waits on is three dynamic imports served by the Vite dev
+      // server every story iframe in the run shares, not a React commit:
+      // 100-150ms cold on an idle server, and past Testing Library's 1000ms
+      // default under the gate's load, which is how the two samples came to
+      // straddle it. The same shape and budget as the Ace-worker wait in
+      // `JsonObjectInput.stories.tsx` (#2292), and for the same reason under
+      // the storybook project's 15s `testTimeout`.
+      { timeout: 10000 },
+    );
+
     // Every section keeps a usable height: none is crushed to nothing by the
     // viewer's content, which is exactly what a content-sized basis did.
     const before = geometry();
@@ -483,12 +522,16 @@ export const LongSkillDocument: Story = {
     // still not settled when the click resolves: `userEvent.click` returns once
     // the event is dispatched, ahead of React committing the `openSections`
     // update, remounting the panel's content, and the browser running the
-    // layout pass that redistributes height across the flex sections. Sampling
-    // once lands mid-redistribution under load and fails intermittently
-    // (#2278), so read the geometry under `waitFor` and let it retry until the
-    // panes stop moving. The reopened layout only ever converges ON `before`
-    // rather than passing through it, so retrying cannot mask a real
-    // regression.
+    // layout pass that redistributes height across the flex sections. So read
+    // the geometry under `waitFor` and let it retry until the panes stop
+    // moving. The reopened layout only ever converges ON `before` rather than
+    // passing through it, so retrying cannot mask a real regression.
+    //
+    // ⚠️ This retry is not what fixed the intermittent `[…, 178, 262]` failure
+    // (#2278, then #2350): that was the Frontmatter highlighter landing between
+    // the two samples — a settled second layout for changed content, which no
+    // amount of retrying converges — and it is removed by the wait above, not
+    // by this one.
     await userEvent.click(viewerControl);
     await userEvent.click(viewerControl);
     await waitFor(async () => {
diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx
index a4855e55..997e1f39 100644
--- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx
+++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx
@@ -2184,6 +2184,31 @@ describe("SkillsScreen directory browsing (#2248)", () => {
     ).not.toBeInTheDocument();
   });
 
+  it("names a skill root echoed back among its own children as not a direct child (#2295)", async () => {
+    // The root is path-less (`skill://data-analysis`), which
+    // `normalizeSkillUri` used to reject — so the containment check failed and
+    // the row read "outside this skill" while being the skill itself.
+    const user = userEvent.setup();
+    const ROOT_SELF = {
+      uri: ROOT,
+      name: "data-analysis",
+      mimeType: "inode/directory",
+    };
+    await openRoot(
+      user,
+      directoryReader({ [ROOT]: { resources: [CHILD_FILE, ROOT_SELF] } }),
+    );
+    const table = within(screen.getByTestId("skill-directory"));
+    expect(table.getByText(/not a direct child/)).toBeInTheDocument();
+    expect(table.queryByText(/outside this skill/)).not.toBeInTheDocument();
+    expect(
+      table.queryByRole("button", { name: `Open directory ${ROOT}` }),
+    ).not.toBeInTheDocument();
+    expect(
+      table.getByRole("button", { name: `View ${CHILD_FILE.uri}` }),
+    ).toBeInTheDocument();
+  });
+
   it("navigates on the normalized URI, so Up cannot walk into a `..` segment", async () => {
     // Containment was decided on the normalized URI while navigation sent and
     // stored the raw one, so for `skill://root/a/../templates` the first Up
diff --git a/clients/web/src/hooks/useTickingClock.test.ts b/clients/web/src/hooks/useTickingClock.test.ts
new file mode 100644
index 00000000..dc459353
--- /dev/null
+++ b/clients/web/src/hooks/useTickingClock.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useTickingClock } from "./useTickingClock";
+
+describe("useTickingClock", () => {
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("reads the seed on the first render, before any tick", () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(5_000_000);
+    const { result } = renderHook(() => useTickingClock(1_000_000));
+    expect(result.current).toBe(1_000_000);
+  });
+
+  it("advances to the wall clock on each interval", () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(5_000_000);
+    const { result } = renderHook(() => useTickingClock(1_000_000, 1000));
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+    expect(result.current).toBe(5_001_000);
+    act(() => {
+      vi.setSystemTime(5_010_000);
+      // Advancing the fake timers also advances the fake clock.
+      vi.advanceTimersByTime(1000);
+    });
+    expect(result.current).toBe(5_011_000);
+  });
+
+  it("never reads behind a seed newer than the last tick", () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(5_000_000);
+    const { result, rerender } = renderHook(
+      ({ seed }) => useTickingClock(seed, 1000),
+      { initialProps: { seed: 1_000_000 } },
+    );
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+    expect(result.current).toBe(5_001_000);
+    // A fresher snapshot arrives before the next tick: it is what the caller
+    // renders from, so it must not be measured against an older clock.
+    rerender({ seed: 5_002_500 });
+    expect(result.current).toBe(5_002_500);
+  });
+
+  it("installs no timer and returns the seed when disabled", () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(5_000_000);
+    const setSpy = vi.spyOn(globalThis, "setInterval");
+    const { result, rerender } = renderHook(
+      ({ enabled }) => useTickingClock(1_000_000, 1000, enabled),
+      { initialProps: { enabled: false } },
+    );
+    expect(setSpy).not.toHaveBeenCalled();
+    act(() => {
+      vi.advanceTimersByTime(3000);
+    });
+    expect(result.current).toBe(1_000_000);
+    // Enabling later starts the clock.
+    rerender({ enabled: true });
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+    expect(result.current).toBe(5_004_000);
+    setSpy.mockRestore();
+  });
+
+  it("stops ticking on unmount", () => {
+    vi.useFakeTimers();
+    const clearSpy = vi.spyOn(globalThis, "clearInterval");
+    const { unmount } = renderHook(() => useTickingClock(0, 250));
+    unmount();
+    expect(clearSpy).toHaveBeenCalled();
+    clearSpy.mockRestore();
+  });
+});
diff --git a/clients/web/src/hooks/useTickingClock.ts b/clients/web/src/hooks/useTickingClock.ts
new file mode 100644
index 00000000..4dc47123
--- /dev/null
+++ b/clients/web/src/hooks/useTickingClock.ts
@@ -0,0 +1,35 @@
+import { useEffect, useState } from "react";
+
+/**
+ * A clock that starts at `seed` and advances to the wall clock once per
+ * `intervalMs` while the component is mounted (#2318).
+ *
+ * For rendering "N seconds ago" from a snapshot stamped with its own
+ * `capturedAt`: the first render reads the snapshot's clock (pure — no
+ * `Date.now()` in render, which `react-hooks/purity` reports), and the
+ * interval then keeps the displayed durations moving while the panel stays
+ * open. The seed also floors the result, so a snapshot newer than the last
+ * tick is never rendered against a clock that predates it — a duration
+ * cannot go negative, and a freshly captured snapshot reads as fresh until the
+ * next tick catches up.
+ *
+ * A timer is exactly the "synchronize with an external system" case an effect
+ * exists for; the state is set only from the interval callback, never in the
+ * effect body, so the first paint is not followed by a corrective re-render.
+ * `enabled: false` installs no timer at all and returns the seed.
+ */
+export function useTickingClock(
+  seed: number,
+  intervalMs = 1000,
+  enabled = true,
+): number {
+  const [now, setNow] = useState(seed);
+  useEffect(() => {
+    // No timer for a consumer with nothing to tick — a component that
+    // renders no durations must not pay for a repeating interval.
+    if (!enabled) return;
+    const id = setInterval(() => setNow(Date.now()), intervalMs);
+    return () => clearInterval(id);
+  }, [intervalMs, enabled]);
+  return Math.max(now, seed);
+}
diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx
index c05b0ae4..14e01cc0 100644
--- a/clients/web/src/test/PreviewAnnotations.stories.tsx
+++ b/clients/web/src/test/PreviewAnnotations.stories.tsx
@@ -8,8 +8,15 @@ import { theme } from "../theme/theme";
 // `.storybook/vitest.setup.ts` used to call `setProjectAnnotations([...])` to
 // hand the preview annotations to the Storybook vitest project. Since Storybook
 // 10.3 `@storybook/addon-vitest` applies them automatically — and *skips* doing
-// so when it finds such a setup file — so the file was removed and the
-// `setupFiles` entry dropped from the `storybook` project in `vite.config.ts`.
+// so when it finds a setup file that is both inside `configDir` and calls
+// `setProjectAnnotations` — so that file was removed.
+//
+// The project does have a `setupFiles` entry again (#2323), and it is a
+// different thing: `src/test/storybookSetup.ts` configures Testing Library's
+// `asyncUtilTimeout` and nothing else. It satisfies *neither* half of the
+// condition above — it lives outside `.storybook/` and names no annotations —
+// so the automatic path is still the one in use, and this guard still asserts
+// that it worked.
 //
 // A green suite is not evidence that the automatic path works. Two things the
 // setup file used to provision are silently losable:
diff --git a/clients/web/src/test/asyncUtilTimeout.test.ts b/clients/web/src/test/asyncUtilTimeout.test.ts
new file mode 100644
index 00000000..e13a8cc6
--- /dev/null
+++ b/clients/web/src/test/asyncUtilTimeout.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from "vitest";
+import { getConfig } from "@testing-library/react";
+
+/**
+ * Pins Testing Library's `asyncUtilTimeout` for the `unit` project (#2323).
+ *
+ * That value governs every `waitFor` and `findBy*` here — 788 call sites — and
+ * is the binding bound on any async assertion, being tighter than the project's
+ * own per-test budget. So it is a number this repo states rather than inherits,
+ * and a Testing Library upgrade that changed its default would fail here rather
+ * than silently move all 788.
+ *
+ * ⚠️ The value is the library's own default, and that is a MEASURED decision,
+ * not a shrug. #2323 proposed 5000, on the reasoning that a contended
+ * happy-dom render is the worst case. Measured on the machine this team uses,
+ * 5000 made the suite worse: three arms of three full unit runs each, same
+ * worktree, comparable load —
+ *
+ *   • unmodified `v2/main`:         3/3 green
+ *   • the raise to 5000:            1, 4 and 0 files timing out at 15000ms
+ *   • the raise reverted:           3/3 green
+ *
+ * A different unrelated set failed each time and every one passed in about a
+ * second in isolation, which is the shape of CPU starvation rather than of a
+ * slow assertion. The mechanism proposed at the time — a wait that is *meant*
+ * to expire spends the whole budget on the happy path, so a 5x raise is a 5x
+ * cost on those tests — was checked on #2335 and does not hold here: no web
+ * test lets a Testing Library wait expire on its passing path, and the same
+ * three-arm protocol re-run interleaved on a leased machine showed no failure
+ * in any arm beyond this very assertion refusing the raised value, and 0 tests
+ * at or above 5000ms with the raise in place. Raising
+ * the budget buys nothing, so the pin stays at the default until a measurement
+ * says otherwise. `setup.ts` carries the full record.
+ *
+ * ⚠️ This asserts the EFFECTIVE value at runtime rather than checking that
+ * `setup.ts` contains a `configure()` call. An earlier revision of #2334 did
+ * the latter by scanning source text, and the review found five ways to satisfy
+ * the scan without configuring anything — a commented-out call, a call on
+ * another object, a non-literal value, and so on. `getConfig()` is what the
+ * `waitFor`s in this project actually read, so there is nothing left to spell
+ * around.
+ */
+describe("Testing Library configuration (unit project)", () => {
+  it("uses the asyncUtilTimeout this repo states, not whatever the library ships", () => {
+    expect(getConfig().asyncUtilTimeout).toBe(1000);
+  });
+});
diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts
index e3bf9d4d..c9ea94b9 100644
--- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts
+++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts
@@ -439,3 +439,186 @@ describe("withRfc8414OidcCompat", () => {
     expect(base).toHaveBeenCalledTimes(2);
   });
 });
+
+describe("probe cancellation (#2319)", () => {
+  const RFC8414 =
+    "https://as.example.com/.well-known/oauth-authorization-server/tenant";
+
+  /** 404 on the RFC 8414 leg, then never answers the OIDC probe. */
+  function stallingProbeFetch() {
+    return vi.fn((input, init) => {
+      if (String(input) === RFC8414) {
+        return Promise.resolve(new Response(null, { status: 404 }));
+      }
+      return new Promise((_resolve, reject) => {
+        // A real `fetch` rejects immediately on a signal that is already
+        // aborted, so the double is only useful if it does the same.
+        if (init?.signal?.aborted) {
+          reject(init.signal.reason as Error);
+          return;
+        }
+        init?.signal?.addEventListener("abort", () =>
+          reject(init.signal?.reason as Error),
+        );
+      });
+    });
+  }
+
+  it("carries the caller's signal into the probe", async () => {
+    const inner = stallingProbeFetch();
+    const wrapped = withRfc8414OidcCompat(inner);
+    const caller = new AbortController();
+
+    const settled = wrapped(RFC8414, { signal: caller.signal }).then(
+      (r) => r,
+      (e: unknown) => e,
+    );
+    // Wait until the probe is actually in flight, so the abort exercises
+    // cancellation rather than the pre-aborted short-circuit.
+    await vi.waitFor(() => {
+      expect(inner).toHaveBeenCalledTimes(2);
+    });
+    const reason = new Error("caller gave up");
+    caller.abort(reason);
+
+    // Not the preceding 404: substituting it would report a discovery failure
+    // for a request the caller deliberately cancelled.
+    expect(await settled).toBe(reason);
+    expect((inner.mock.calls[1][1] as RequestInit).signal).toBe(caller.signal);
+  });
+
+  it("does not issue a probe the caller already cancelled", async () => {
+    const inner = stallingProbeFetch();
+    const wrapped = withRfc8414OidcCompat(inner);
+    const reason = new Error("already gone");
+
+    const settled = wrapped(RFC8414, {
+      signal: AbortSignal.abort(reason),
+    }).then(
+      (r) => r,
+      (e: unknown) => e,
+    );
+
+    expect(await settled).toBe(reason);
+    // Only the RFC 8414 leg, which the caller did make.
+    expect(inner).toHaveBeenCalledTimes(1);
+  });
+
+  it("rethrows an abort that lands while the probe body is pending", async () => {
+    // `fetch` resolves on headers, so a late abort rejects `probe.text()`
+    // rather than the fetch — a separate catch, which used to answer with the
+    // preceding 404.
+    const caller = new AbortController();
+    const inner = vi.fn((input, init) => {
+      if (String(input) === RFC8414) {
+        return Promise.resolve(new Response(null, { status: 404 }));
+      }
+      // Headers now, body never.
+      return Promise.resolve(
+        new Response(
+          new ReadableStream({
+            start(ctrl) {
+              ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
+              init?.signal?.addEventListener("abort", () =>
+                ctrl.error(init.signal?.reason as Error),
+              );
+            },
+          }),
+          { headers: { "content-type": "application/json" } },
+        ),
+      );
+    });
+    const wrapped = withRfc8414OidcCompat(inner);
+
+    const settled = wrapped(RFC8414, { signal: caller.signal }).then(
+      (r) => r,
+      (e: unknown) => e,
+    );
+    await vi.waitFor(() => {
+      expect(inner).toHaveBeenCalledTimes(2);
+    });
+    const reason = new Error("caller gave up mid-body");
+    caller.abort(reason);
+
+    expect(await settled).toBe(reason);
+  });
+
+  it("releases the discarded discovery response on an exceptional exit", async () => {
+    // Nobody receives `response` when the loop throws, and an unread body holds
+    // its connection open on Node/undici — the same reason the successful
+    // substitution path releases it.
+    const cancels = vi.fn<() => Promise>().mockResolvedValue(undefined);
+    const discovery = new Response(
+      new ReadableStream({
+        start(ctrl) {
+          ctrl.enqueue(new TextEncoder().encode("not found"));
+        },
+      }),
+      { status: 404 },
+    );
+    // Spy on the body's cancel, which is what `releaseBody` calls.
+    const body = discovery.body;
+    if (body) vi.spyOn(body, "cancel").mockImplementation(cancels);
+
+    const inner = vi.fn((input) =>
+      String(input) === RFC8414
+        ? Promise.resolve(discovery)
+        : new Promise(() => {}),
+    );
+    const wrapped = withRfc8414OidcCompat(inner);
+
+    const settled = wrapped(RFC8414, {
+      signal: AbortSignal.abort(new Error("gone")),
+    }).then(
+      () => "resolved",
+      () => "threw",
+    );
+
+    expect(await settled).toBe("threw");
+    expect(cancels).toHaveBeenCalled();
+  });
+
+  it("does not wait on a cancel that never settles", async () => {
+    // ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel
+    // promise, which is permitted never to settle — so awaiting the release on
+    // a path that is propagating a cancellation would hang the very thing it
+    // was meant to end.
+    const discovery = new Response(
+      new ReadableStream({
+        start(ctrl) {
+          ctrl.enqueue(new TextEncoder().encode("not found"));
+        },
+        cancel() {
+          return new Promise(() => {});
+        },
+      }),
+      { status: 404 },
+    );
+    const inner = vi.fn((input) =>
+      String(input) === RFC8414
+        ? Promise.resolve(discovery)
+        : new Promise(() => {}),
+    );
+    const wrapped = withRfc8414OidcCompat(inner);
+    const reason = new Error("gone");
+
+    // Would hang if the release were awaited; the assertion is that it settles.
+    await expect(
+      wrapped(RFC8414, { signal: AbortSignal.abort(reason) }),
+    ).rejects.toBe(reason);
+  });
+
+  it("still falls back to the original response on an ordinary probe failure", async () => {
+    const inner = vi.fn((input) => {
+      if (String(input) === RFC8414) {
+        return Promise.resolve(new Response(null, { status: 404 }));
+      }
+      return Promise.reject(new TypeError("network error"));
+    });
+    const wrapped = withRfc8414OidcCompat(inner);
+
+    const response = await wrapped(RFC8414);
+
+    expect(response.status).toBe(404);
+  });
+});
diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts
new file mode 100644
index 00000000..5f5b0465
--- /dev/null
+++ b/clients/web/src/test/core/auth/requestTimeout.test.ts
@@ -0,0 +1,895 @@
+/**
+ * Unit coverage for `withOAuthRequestTimeout` (#2319) — the deadline every
+ * OAuth-path request runs under.
+ *
+ * What is pinned here, and why each case exists rather than being obvious:
+ *
+ * - **The bound itself**, including the *body*: `fetch` resolves on headers, so
+ *   a server that sends them and then stalls has to be caught by the buffering
+ *   race, not by the fetch promise.
+ * - **The response's identity.** The bound drains a *clone* and hands back the
+ *   original, so the cases assert identity, that the body is still readable
+ *   after the drain, and that a caller's own `clone()` keeps its metadata —
+ *   the property a rebuilt response could not have, since `url` / `redirected`
+ *   / `type` are internal slots.
+ * - **Cancellation semantics.** The caller's signal is forwarded *and* raced,
+ *   so the tests distinguish the two: one uses a fetch that honours the signal,
+ *   another uses one that ignores it entirely.
+ * - **Composition with `withRfc8414OidcCompat`**, written as a contrast between
+ *   the two orderings. It is the ordering that is under test, not one
+ *   arrangement's behaviour, so the wrong one is asserted to misattribute.
+ *
+ * Fake timers throughout: every budget here is exercised by advancing the clock
+ * rather than by waiting, so the suite costs milliseconds.
+ */
+import { describe, it, expect, vi, afterEach } from "vitest";
+import {
+  DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
+  OAuthRequestTimeoutError,
+  deadlineForRequestInit,
+  exemptMcpEndpoint,
+  withOAuthRequestTimeout,
+} from "@inspector/core/auth/requestTimeout.js";
+import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
+
+const URL_UNDER_TEST =
+  "https://as.example.com/.well-known/oauth-authorization-server";
+
+/** A fetch that never settles — the wedged authorization server of #2319. */
+const neverSettles: typeof fetch = () => new Promise(() => {});
+
+afterEach(() => {
+  vi.useRealTimers();
+  vi.restoreAllMocks();
+});
+
+describe("withOAuthRequestTimeout", () => {
+  it("passes a prompt response through", async () => {
+    // The same object: the body is drained through a clone under the deadline
+    // (see the stalled-body case below), so nothing about the response the
+    // caller receives changes.
+    const inner = vi
+      .fn()
+      .mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
+    const wrapped = withOAuthRequestTimeout(inner, 1000);
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({ ok: true });
+    expect(inner).toHaveBeenCalledTimes(1);
+  });
+
+  it("rejects a stalled request with an error naming the endpoint", async () => {
+    vi.useFakeTimers();
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
+
+    const pending = wrapped(URL_UNDER_TEST);
+    const assertion = expect(pending).rejects.toThrow(
+      `OAuth request to ${URL_UNDER_TEST} timed out after 1000ms`,
+    );
+    await vi.advanceTimersByTimeAsync(1000);
+    await assertion;
+  });
+
+  it("reports the timeout as OAuthRequestTimeoutError carrying url and budget", async () => {
+    vi.useFakeTimers();
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
+
+    const pending = wrapped(new URL(URL_UNDER_TEST));
+    const assertion = pending.catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+    const err = await assertion;
+
+    expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
+    const timeout = err as OAuthRequestTimeoutError;
+    expect(timeout.name).toBe("OAuthRequestTimeoutError");
+    expect(timeout.url).toBe(URL_UNDER_TEST);
+    expect(timeout.timeoutMs).toBe(1000);
+  });
+
+  it("redacts sensitive query values from the endpoint it names", async () => {
+    vi.useFakeTimers();
+    // This message is recorded verbatim by `createFetchTracker` into the
+    // Network log and the persisted session, and an OAuth endpoint's query
+    // string can carry a `code`, an `access_token` or a `client_secret` —
+    // which `fetchTracking` already redacts everywhere else it records a URL.
+    const sensitive =
+      "https://as.example.com/token?code=abc123&client_secret=shhh&state=keepme";
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
+
+    const assertion = wrapped(sensitive).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+    const err = (await assertion) as OAuthRequestTimeoutError;
+
+    for (const secret of ["abc123", "shhh"]) {
+      expect(err.message).not.toContain(secret);
+      expect(err.url).not.toContain(secret);
+    }
+    // Still identifiable, which was the point of naming the endpoint: the path
+    // and every non-sensitive parameter survive. Read through `URL`, since
+    // `URLSearchParams.toString()` percent-encodes the redaction marker.
+    const redacted = new URL(err.url);
+    expect(redacted.origin + redacted.pathname).toBe(
+      "https://as.example.com/token",
+    );
+    expect(redacted.searchParams.get("state")).toBe("keepme");
+    expect(redacted.searchParams.get("code")).toBe("[REDACTED]");
+    expect(redacted.searchParams.get("client_secret")).toBe("[REDACTED]");
+  });
+
+  it("takes the URL off a Request object too", async () => {
+    vi.useFakeTimers();
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
+
+    const pending = wrapped(new Request(URL_UNDER_TEST, { method: "POST" }));
+    const assertion = pending.catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+      url: URL_UNDER_TEST,
+    });
+  });
+
+  it("aborts the underlying fetch rather than leaving it running detached", async () => {
+    vi.useFakeTimers();
+    let seen: AbortSignal | undefined;
+    const inner: typeof fetch = (_input, init) => {
+      seen = init?.signal ?? undefined;
+      return new Promise((_resolve, reject) => {
+        init?.signal?.addEventListener("abort", () =>
+          reject(new Error("aborted by signal")),
+        );
+      });
+    };
+    const wrapped = withOAuthRequestTimeout(inner, 1000);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect(seen?.aborted).toBe(true);
+    // The underlying rejection can win the race against our own; either way the
+    // caller must be told it was a timeout, and which endpoint stalled.
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+  });
+
+  it("still enforces the deadline when the fetch ignores the signal", async () => {
+    vi.useFakeTimers();
+    // The signal now reaches all the way out — `createRemoteFetch` forwards it
+    // onto the proxy hop and `/api/fetch` composes it into its outbound call —
+    // but a `fetchFn` that ignores `AbortSignal` cancels nothing, so the race
+    // is what bounds this case.
+    const signalIgnoring: typeof fetch = () => new Promise(() => {});
+    const wrapped = withOAuthRequestTimeout(signalIgnoring, 1000);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+  });
+
+  it("forwards a caller-supplied signal to the inner fetch", async () => {
+    const inner: typeof fetch = (_input, init) =>
+      new Promise((_resolve, reject) => {
+        init?.signal?.addEventListener("abort", () =>
+          reject(new Error("caller cancelled")),
+        );
+      });
+    const wrapped = withOAuthRequestTimeout(inner, 60_000);
+
+    const caller = new AbortController();
+    const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal });
+    caller.abort();
+
+    await expect(pending).rejects.toThrow("caller cancelled");
+  });
+
+  it("races caller cancellation too, so it wins on a signal-ignoring fetch", async () => {
+    // Forwarding alone is not enough against a `fetchFn` that ignores the
+    // signal: without the race the caller's abort would sit pending for the
+    // whole budget instead of winning.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
+
+    const caller = new AbortController();
+    const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal });
+    const reason = new Error("caller gave up");
+    caller.abort(reason);
+
+    await expect(pending).rejects.toBe(reason);
+  });
+
+  it("rejects an already-aborted signal without sending the request", async () => {
+    const inner = vi.fn(neverSettles);
+    const wrapped = withOAuthRequestTimeout(inner, 60_000);
+    const reason = new Error("already gone");
+
+    await expect(
+      wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }),
+    ).rejects.toBe(reason);
+    // Racing it would still have evaluated the inner fetch, and a fetch that
+    // does not check an already-aborted signal would send the request.
+    expect(inner).not.toHaveBeenCalled();
+  });
+
+  it("treats an undefined init.signal as absent, keeping a Request's own", async () => {
+    // `RequestInit` is a WebIDL dictionary: a member present as `undefined` is
+    // converted as absent. `{ ...base, signal: undefined }` is what a spread
+    // over an options object with no signal produces.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
+
+    const caller = new AbortController();
+    const pending = wrapped(
+      new Request(URL_UNDER_TEST, { signal: caller.signal }),
+      { signal: undefined },
+    );
+    const reason = new Error("request cancelled");
+    caller.abort(reason);
+
+    await expect(pending).rejects.toBe(reason);
+  });
+
+  it("honours the signal embedded in a Request input", async () => {
+    // `init.signal` is absent here, so the Request's own signal is the caller's
+    // — reading `init` alone would silently override it.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
+
+    const caller = new AbortController();
+    const pending = wrapped(
+      new Request(URL_UNDER_TEST, { signal: caller.signal }),
+    );
+    const reason = new Error("request cancelled");
+    caller.abort(reason);
+
+    await expect(pending).rejects.toBe(reason);
+  });
+
+  it("lets an explicit null init.signal override a Request's own", async () => {
+    vi.useFakeTimers();
+    // Per the fetch spec a present `signal` key wins, `null` included.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
+
+    const caller = new AbortController();
+    const pending = wrapped(
+      new Request(URL_UNDER_TEST, { signal: caller.signal }),
+      { signal: null },
+    );
+    const assertion = pending.catch((err: unknown) => err);
+    caller.abort(new Error("ignored"));
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+  });
+
+  it("removes its abort listener once the request settles", async () => {
+    const caller = new AbortController();
+    const removeSpy = vi.spyOn(caller.signal, "removeEventListener");
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(new Response("{}")),
+      60_000,
+    );
+
+    await wrapped(URL_UNDER_TEST, { signal: caller.signal });
+
+    // A caller signal outlives one request — one connect attempt makes several
+    // — so a listener left behind per request would accumulate on it.
+    expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
+  });
+
+  it("bounds a stalled response body, not just the headers", async () => {
+    vi.useFakeTimers();
+    // `fetch` resolves on headers. A server that sends them and then stalls
+    // would leave the caller's `response.json()` hanging unwatched.
+    const stalledBody: typeof fetch = () =>
+      Promise.resolve(
+        new Response(
+          new ReadableStream({
+            start(ctrl) {
+              ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
+            },
+          }),
+          { headers: { "content-type": "application/json" } },
+        ),
+      );
+    const wrapped = withOAuthRequestTimeout(stalledBody, 1000);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+  });
+
+  it("releases both tee branches when the deadline wins", async () => {
+    vi.useFakeTimers();
+    // Losing the race rejects the caller; on its own it does not stop the read.
+    // A drain still running would keep pulling bytes *and* keep buffering them
+    // for the unread original branch — unbounded memory on exactly the
+    // signal-ignoring body the race exists to contain. Observed through the
+    // stream's own `cancel`, which is what tearing the branches down calls.
+    let cancelled = false;
+    const stalled = new ReadableStream({
+      start(ctrl) {
+        ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
+      },
+      // Never resolves: headers arrived, the body never finishes.
+      pull() {
+        return new Promise(() => {});
+      },
+      cancel() {
+        cancelled = true;
+      },
+    });
+    const wrapped = withOAuthRequestTimeout(
+      vi
+        .fn()
+        .mockResolvedValue(
+          new Response(stalled, { headers: { "content-type": "text/plain" } }),
+        ),
+      1000,
+    );
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+    // The cancels are deliberately not awaited — cancelling one branch of a tee
+    // can wait on the other — so let them settle before observing.
+    for (let i = 0; i < 20; i++) await Promise.resolve();
+    expect(cancelled).toBe(true);
+  });
+
+  it("still reports the timeout when tearing the branches down fails", async () => {
+    vi.useFakeTimers();
+    // Cancelling a stream that is already errored, locked elsewhere, or whose
+    // source rejects is not a failure here — the cleanup runs on a path that is
+    // already rejecting with something the caller cares about far more.
+    const hostile = new ReadableStream({
+      start(ctrl) {
+        ctrl.enqueue(new TextEncoder().encode("x"));
+      },
+      pull() {
+        return new Promise(() => {});
+      },
+      cancel() {
+        throw new Error("cancel blew up");
+      },
+    });
+    const wrapped = withOAuthRequestTimeout(
+      vi
+        .fn()
+        .mockResolvedValue(
+          new Response(hostile, { headers: { "content-type": "text/plain" } }),
+        ),
+      1000,
+    );
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
+    // And no unhandled rejection escapes from the cleanup.
+    for (let i = 0; i < 20; i++) await Promise.resolve();
+  });
+
+  it("hands back the original response, not a copy", async () => {
+    const original = new Response('{"issuer":"https://as.example.com"}', {
+      status: 201,
+      statusText: "Created",
+      headers: { "content-type": "application/json", "x-probe": "kept" },
+    });
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(original),
+      1000,
+    );
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    // Identity, which is the strongest form of "nothing was changed": every
+    // native slot, the headers guard and `clone()` behave as they would if this
+    // wrapper were not in the chain at all.
+    expect(response).toBe(original);
+    await expect(response.json()).resolves.toEqual({
+      issuer: "https://as.example.com",
+    });
+  });
+
+  it("leaves the body readable after draining the clone", async () => {
+    // The bound works by draining a clone; teeing buffers the other branch, so
+    // the caller must still be able to read the original without a second trip.
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(new Response("hello")),
+      1000,
+    );
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    expect(response.bodyUsed).toBe(false);
+    await expect(response.text()).resolves.toBe("hello");
+  });
+
+  it("leaves the caller free to clone it again, with metadata intact", async () => {
+    // The case a rebuilt response could never satisfy: `url` / `redirected` /
+    // `type` are internal slots, so a shadowed own property survives a direct
+    // read and is lost the moment anyone calls `clone()`.
+    const original = new Response("{}", { status: 200 });
+    Object.defineProperty(original, "url", {
+      value: "https://as.example.com/redirected",
+      configurable: true,
+    });
+    Object.defineProperty(original, "redirected", {
+      value: true,
+      configurable: true,
+    });
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(original),
+      1000,
+    );
+
+    const response = await wrapped(URL_UNDER_TEST);
+    const copy = response.clone();
+
+    expect(copy.url).toBe("https://as.example.com/redirected");
+    expect(copy.redirected).toBe(true);
+    await expect(copy.json()).resolves.toEqual({});
+  });
+
+  it("keeps content-encoding and content-length, which are the server's own", async () => {
+    // Nothing is rebuilt, so nothing invalidates them: the caller gets the
+    // response `fetch` produced and decodes it the way `fetch` intends.
+    const original = new Response("{}", {
+      headers: {
+        "content-type": "application/json",
+        "content-encoding": "gzip",
+      },
+    });
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(original),
+      1000,
+    );
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    expect(response.headers.get("content-encoding")).toBe("gzip");
+  });
+
+  it("passes a null-body status through untouched", async () => {
+    const original = new Response(null, { status: 204 });
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(original),
+      1000,
+    );
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    expect(response).toBe(original);
+    expect(response.status).toBe(204);
+    expect(response.body).toBeNull();
+  });
+
+  it("propagates a non-timeout failure unchanged", async () => {
+    const boom = new TypeError("network error");
+    const inner = vi.fn().mockRejectedValue(boom);
+    const wrapped = withOAuthRequestTimeout(inner, 1000);
+
+    await expect(wrapped(URL_UNDER_TEST)).rejects.toBe(boom);
+  });
+
+  it("rounds a fractional budget, which is reported to the caller", async () => {
+    vi.useFakeTimers();
+    // `setTimeout` would accept the fraction and truncate it. The rounding is
+    // for the budget's *reported* form: a `performance.now()` subtraction would
+    // otherwise name "1000.4000000953674ms" in the message and expose a
+    // non-integer `timeoutMs`.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 1000.4);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+      timeoutMs: 1000,
+    });
+  });
+
+  it("falls back to the default on a non-finite budget", async () => {
+    vi.useFakeTimers();
+    // `NaN` survives `Math.max(0, Math.round(NaN))` and `setTimeout(fn, NaN)`
+    // fires immediately, so without this every OAuth request under a budget
+    // that came out of bad arithmetic would fail at once with "timed out after
+    // NaNms".
+    for (const bad of [NaN, Infinity, -Infinity]) {
+      const wrapped = withOAuthRequestTimeout(neverSettles, bad);
+      const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+
+      await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
+      expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
+        "pending",
+      );
+
+      await vi.advanceTimersByTimeAsync(1);
+      expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+        timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
+      });
+    }
+  });
+
+  it("clamps an over-large budget to what setTimeout can schedule", async () => {
+    vi.useFakeTimers();
+    // Past 2**31-1 the delay overflows a 32-bit signed int and Node falls back
+    // to 1ms — an immediate timeout, the opposite of what was asked for.
+    const wrapped = withOAuthRequestTimeout(neverSettles, 2 ** 40);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(1000);
+    expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
+      "pending",
+    );
+
+    await vi.advanceTimersByTimeAsync(2_147_483_647);
+    expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+      timeoutMs: 2_147_483_647,
+    });
+  });
+
+  it("clamps a negative budget to zero rather than throwing", async () => {
+    vi.useFakeTimers();
+    const wrapped = withOAuthRequestTimeout(neverSettles, -1);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(0);
+
+    expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+      timeoutMs: 0,
+    });
+  });
+
+  it("defaults to a generous budget a slow authorization server can meet", async () => {
+    vi.useFakeTimers();
+    const wrapped = withOAuthRequestTimeout(neverSettles);
+
+    const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
+    await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
+    expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
+      "pending",
+    );
+
+    await vi.advanceTimersByTimeAsync(1);
+    expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
+      timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
+    });
+  });
+
+  describe("the deadline stamped on the init (#2319 proxy hop)", () => {
+    // How the budget reaches `/api/fetch` without travelling upstream: a header
+    // would be copied verbatim into the payload and re-sent to the
+    // authorization server, so the carrier is a WeakMap keyed on the init.
+    it("stamps the budget on the init it hands down", async () => {
+      const inner = vi.fn().mockResolvedValue(new Response("{}"));
+      const wrapped = withOAuthRequestTimeout(inner, 1234);
+
+      await wrapped(URL_UNDER_TEST);
+
+      expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1234);
+    });
+
+    it("stamps the rounded budget, matching what is reported", async () => {
+      const inner = vi.fn().mockResolvedValue(new Response("{}"));
+      const wrapped = withOAuthRequestTimeout(inner, 1000.4);
+
+      await wrapped(URL_UNDER_TEST);
+
+      expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1000);
+    });
+
+    it("stamps nothing on an exempt request", async () => {
+      // The route must apply no deadline to MCP traffic, and it decides that
+      // from the absence of a budget in the envelope.
+      const inner = vi.fn().mockResolvedValue(new Response("{}"));
+      const wrapped = withOAuthRequestTimeout(inner, 1234, () => true);
+
+      await wrapped(URL_UNDER_TEST, { method: "POST" });
+
+      expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBeUndefined();
+    });
+
+    it("reads nothing off an init that never went through the wrapper", () => {
+      expect(deadlineForRequestInit(undefined)).toBeUndefined();
+      expect(deadlineForRequestInit({})).toBeUndefined();
+      expect(deadlineForRequestInit({ method: "GET" })).toBeUndefined();
+    });
+  });
+
+  describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => {
+    const SERVER = "https://srv.example.com/mcp";
+
+    /** Most cases are plain JSON-RPC traffic, which carries no form media type. */
+    const JSON_HEADERS = new Headers({ "content-type": "application/json" });
+    /** The token family: RFC 6749 §4.1.3 and RFC 7009 §2.1 are form-encoded. */
+    const FORM_HEADERS = new Headers({
+      "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
+    });
+
+    /** Streamable HTTP: every MCP request goes to the configured URL. */
+    const streamable = (serverUrl: string) =>
+      exemptMcpEndpoint(
+        () => serverUrl,
+        () => false,
+      );
+    /** Legacy SSE: the message endpoint arrives inside the stream. */
+    const sse = (serverUrl: string) =>
+      exemptMcpEndpoint(
+        () => serverUrl,
+        () => true,
+      );
+
+    it("exempts the MCP endpoint itself, whatever method or query", () => {
+      const isExempt = exemptMcpEndpoint(() => SERVER);
+
+      expect(isExempt(SERVER, JSON_HEADERS)).toBe(true);
+      expect(isExempt(`${SERVER}?sessionId=abc`, JSON_HEADERS)).toBe(true);
+      expect(isExempt(`${SERVER}#frag`, JSON_HEADERS)).toBe(true);
+    });
+
+    it("never exempts a form-encoded request, whatever URL it uses", () => {
+      // `oauthTokenUrl` takes any absolute URL, including the MCP endpoint's
+      // own — at which point a URL rule would exempt the transport-internal
+      // refresh, the one request this change exists to bound. The token family
+      // is form-encoded and MCP never is, so the media type settles it first.
+      for (const isExempt of [
+        streamable(SERVER),
+        sse(SERVER),
+        exemptMcpEndpoint(() => SERVER),
+      ]) {
+        expect(isExempt(SERVER, FORM_HEADERS)).toBe(false);
+      }
+      // And a missing server URL no longer fails open past it either.
+      expect(exemptMcpEndpoint(() => undefined)(SERVER, FORM_HEADERS)).toBe(
+        false,
+      );
+    });
+
+    it("bounds same-origin OAuth work on a Streamable HTTP connection", () => {
+      // The narrow rule, available because the endpoint is the configured URL.
+      const isExempt = streamable(SERVER);
+
+      expect(isExempt(SERVER, JSON_HEADERS)).toBe(true);
+      expect(
+        isExempt(
+          "https://srv.example.com/.well-known/oauth-protected-resource/mcp",
+          JSON_HEADERS,
+        ),
+      ).toBe(false);
+      expect(isExempt("https://srv.example.com/token", JSON_HEADERS)).toBe(
+        false,
+      );
+    });
+
+    it("widens to the origin on legacy SSE, whose message path is unknowable", () => {
+      // The cost of the wider rule, asserted rather than implied: same-origin
+      // OAuth is exempt here, and falls back to the SDK's per-request timeout.
+      const isExempt = sse("https://srv.example.com/sse");
+
+      expect(
+        isExempt(
+          "https://srv.example.com/.well-known/openid-configuration",
+          JSON_HEADERS,
+        ),
+      ).toBe(true);
+      expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(
+        false,
+      );
+    });
+
+    it("falls back to the origin rule when the transport is unknown", () => {
+      // Fails open on this uncertainty like every other: a path rule applied to
+      // a connection that turns out to be SSE severs real MCP traffic.
+      const noHint = exemptMcpEndpoint(() => SERVER);
+      const throws = exemptMcpEndpoint(
+        () => SERVER,
+        () => {
+          throw new Error("not yet connected");
+        },
+      );
+
+      expect(noHint("https://srv.example.com/token", JSON_HEADERS)).toBe(true);
+      expect(throws("https://srv.example.com/token", JSON_HEADERS)).toBe(true);
+    });
+
+    it("exempts legacy SSE's separate message endpoint", () => {
+      // The case a path rule gets wrong: `SSEClientTransport` opens the
+      // configured URL and is handed a *different* pathname to POST every
+      // JSON-RPC message to. Bounding those would sever a slow tool call over
+      // SSE and report it as an OAuth timeout. The SDK enforces that the
+      // message endpoint shares the stream URL's origin, which is what makes
+      // an origin rule exact rather than approximate.
+      const isExempt = exemptMcpEndpoint(() => "https://srv.example.com/sse");
+
+      expect(
+        isExempt(
+          "https://srv.example.com/messages?sessionId=abc",
+          JSON_HEADERS,
+        ),
+      ).toBe(true);
+      expect(isExempt("https://srv.example.com/", JSON_HEADERS)).toBe(true);
+    });
+
+    it("bounds OAuth work on a different origin", () => {
+      const isExempt = exemptMcpEndpoint(() => SERVER);
+
+      expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(
+        false,
+      );
+      expect(
+        isExempt(
+          "https://as.example.com/.well-known/openid-configuration",
+          JSON_HEADERS,
+        ),
+      ).toBe(false);
+      // Origin is scheme + host + port, so any of the three differing bounds it.
+      expect(isExempt("http://srv.example.com/token", JSON_HEADERS)).toBe(
+        false,
+      );
+      expect(isExempt("https://srv.example.com:8443/token", JSON_HEADERS)).toBe(
+        false,
+      );
+    });
+
+    it("fails open when the server URL is unknown or unparseable", () => {
+      // The two errors are not symmetric: bounding what should not be bounded
+      // severs a long-running tool call, while failing to bound leaves the
+      // SDK's own per-request timeout as the backstop it already was.
+      expect(
+        exemptMcpEndpoint(() => undefined)(
+          "https://as.example.com/token",
+          JSON_HEADERS,
+        ),
+      ).toBe(true);
+      expect(
+        exemptMcpEndpoint(() => "")(
+          "https://as.example.com/token",
+          JSON_HEADERS,
+        ),
+      ).toBe(true);
+      expect(exemptMcpEndpoint(() => "not a url")(SERVER, JSON_HEADERS)).toBe(
+        true,
+      );
+      expect(exemptMcpEndpoint(() => SERVER)("not a url", JSON_HEADERS)).toBe(
+        true,
+      );
+    });
+
+    it("fails open when the server-URL getter throws", () => {
+      // `InspectorClient.getServerUrl()` throws for a non-HTTP configuration,
+      // and this wrapped fetch can still be handed to a transport or a custom
+      // factory that calls it — propagating a configuration error out of a
+      // fetch would turn the documented fail-open into a hard failure.
+      const isExempt = exemptMcpEndpoint(() => {
+        throw new Error("Server URL is only available for HTTP transports");
+      });
+
+      expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(true);
+      // Still not past the form-encoded check, which runs first.
+      expect(isExempt("https://as.example.com/token", FORM_HEADERS)).toBe(
+        false,
+      );
+    });
+
+    it("reads the server URL per call, since it changes between connects", () => {
+      let current = SERVER;
+      const isExempt = exemptMcpEndpoint(() => current);
+
+      expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe(
+        false,
+      );
+      current = "https://other.example.com/mcp";
+      expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe(
+        true,
+      );
+      expect(isExempt(SERVER, JSON_HEADERS)).toBe(false);
+    });
+  });
+
+  it("passes an exempt request through with no deadline and no buffering", async () => {
+    vi.useFakeTimers();
+    // Untouched means untouched: no signal of ours, and the body is left as a
+    // live stream rather than buffered — which is what an SSE response needs.
+    const body = new ReadableStream({
+      start(ctrl) {
+        ctrl.enqueue(new TextEncoder().encode("data: hi\n\n"));
+      },
+    });
+    const original = new Response(body, {
+      headers: { "content-type": "text/event-stream" },
+    });
+    const inner = vi.fn().mockResolvedValue(original);
+    const wrapped = withOAuthRequestTimeout(inner, 1000, () => true);
+
+    const response = await wrapped(URL_UNDER_TEST);
+
+    expect(response).toBe(original);
+    expect(
+      (inner.mock.calls[0][1] as RequestInit | undefined)?.signal,
+    ).toBeUndefined();
+    // Nothing is armed, so advancing past the budget cannot sever it.
+    await vi.advanceTimersByTimeAsync(5000);
+    expect(response.bodyUsed).toBe(false);
+  });
+
+  describe("composed under withRfc8414OidcCompat (#2319 ordering)", () => {
+    // `withRfc8414OidcCompat` re-fetches an OIDC candidate after a failed RFC
+    // 8414 discovery, so where the deadline sits relative to it decides which
+    // request is being timed. `InspectorClient` and the CLI both put it
+    // innermost; these two cases are why.
+    const RFC8414 =
+      "https://as.example.com/.well-known/oauth-authorization-server/tenant";
+    // A path-suffixed RFC 8414 candidate has two OIDC siblings, and the compat
+    // wrapper tries both.
+    const PROBES = [
+      "https://as.example.com/.well-known/openid-configuration/tenant",
+      "https://as.example.com/tenant/.well-known/openid-configuration",
+    ];
+
+    /** Answers the RFC 8414 leg with a prompt 404, then stalls on the probe. */
+    function stallingProbeFetch() {
+      return vi.fn((input) => {
+        if (String(input) === RFC8414) {
+          return Promise.resolve(new Response(null, { status: 404 }));
+        }
+        return new Promise(() => {});
+      });
+    }
+
+    it("outside, a stalled probe is misreported under the preceding URL", async () => {
+      vi.useFakeTimers();
+      const inner = stallingProbeFetch();
+      const wrapped = withOAuthRequestTimeout(
+        withRfc8414OidcCompat(inner),
+        1000,
+      );
+
+      const assertion = wrapped(RFC8414).catch((err: unknown) => err);
+      await vi.advanceTimersByTimeAsync(1000);
+
+      const err = (await assertion) as OAuthRequestTimeoutError;
+      expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
+      // The endpoint that stalled was PROBE. One budget covers both legs, and
+      // the error names the request the caller made rather than the one that
+      // hung — exactly the diagnostic this change exists to provide.
+      expect(err.url).toBe(RFC8414);
+    });
+
+    it("innermost, the probe's own timeout escapes and names the probe", async () => {
+      vi.useFakeTimers();
+      const inner = stallingProbeFetch();
+      const wrapped = withRfc8414OidcCompat(
+        withOAuthRequestTimeout(inner, 1000),
+      );
+
+      const assertion = wrapped(RFC8414).catch((err: unknown) => err);
+      // The probe is timed from when it starts, on a budget of its own — not on
+      // whatever an outer one had left after the RFC 8414 leg.
+      await vi.advanceTimersByTimeAsync(1000);
+
+      const err = (await assertion) as OAuthRequestTimeoutError;
+      // The compat wrapper is inert on an ordinary probe failure, but a deadline
+      // the Inspector imposed escapes it, so the caller is told which endpoint
+      // stalled rather than being handed the preceding 404.
+      expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
+      expect(err.url).toBe(PROBES[0]);
+      expect(inner).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  it("clears its timer once the request settles", async () => {
+    vi.useFakeTimers();
+    const clearSpy = vi.spyOn(globalThis, "clearTimeout");
+    const wrapped = withOAuthRequestTimeout(
+      vi.fn().mockResolvedValue(new Response("{}")),
+      1000,
+    );
+
+    await wrapped(URL_UNDER_TEST);
+
+    expect(clearSpy).toHaveBeenCalled();
+    // Nothing is left to fire, so no unhandled rejection can surface later.
+    await vi.advanceTimersByTimeAsync(5000);
+  });
+});
diff --git a/clients/web/src/test/core/auth/revocation.test.ts b/clients/web/src/test/core/auth/revocation.test.ts
index e50fa1e9..c108b530 100644
--- a/clients/web/src/test/core/auth/revocation.test.ts
+++ b/clients/web/src/test/core/auth/revocation.test.ts
@@ -393,11 +393,11 @@ describe("revokeToken", () => {
     });
   });
 
-  // The web fetch is `createRemoteFetch`, which re-issues the call as a POST to
-  // `/api/fetch` and drops `init.signal`; the backend's outbound fetch gets no
-  // signal either. A signal-only bound is therefore inert on exactly the path
-  // the timeout exists for, so the deadline has to hold against a fetch that
-  // ignores the signal entirely.
+  // The signal reaches every path since #2319 — `createRemoteFetch` forwards it
+  // onto the `/api/fetch` hop and that route composes it into its outbound
+  // fetch — but a signal-only bound is still only as good as the fetch beneath
+  // it. A `fetchFn` that ignores `AbortSignal`, or a wedged backend, would
+  // leave a signal-only deadline inert, so it has to hold without one.
   it("gives up on a fetch that never settles and ignores the signal", async () => {
     const outcome = await revokeToken({
       endpoint: REVOKE_URL,
diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts
index bb2b7712..a3d94239 100644
--- a/clients/web/src/test/core/client/config.test.ts
+++ b/clients/web/src/test/core/client/config.test.ts
@@ -206,6 +206,19 @@ describe("client config", () => {
     ).toThrow(/HTTPS/);
   });
 
+  it("parseClientConfig accepts a loopback http CIMD URL", () => {
+    for (const clientMetadataUrl of [
+      "http://localhost:8090/client-metadata.json",
+      "http://127.0.0.1:8090/client-metadata.json",
+      "http://[::1]:8090/client-metadata.json",
+    ]) {
+      const config = parseClientConfig({
+        cimd: { enabled: true, clientMetadataUrl },
+      });
+      expect(config.cimd?.clientMetadataUrl).toBe(clientMetadataUrl);
+    }
+  });
+
   it("parseClientConfig rejects CIMD URL without path", () => {
     expect(() =>
       parseClientConfig({
@@ -299,6 +312,70 @@ describe("client config", () => {
         CIMD_METADATA_URL_PATH_ERROR,
       );
     });
+
+    it("accepts plain http on the three exempt loopback literals", () => {
+      expect(
+        getCimdClientMetadataUrlError(
+          "http://localhost:8090/client-metadata.json",
+        ),
+      ).toBeUndefined();
+      expect(
+        getCimdClientMetadataUrlError(
+          "http://127.0.0.1:8090/client-metadata.json",
+        ),
+      ).toBeUndefined();
+      expect(
+        getCimdClientMetadataUrlError("http://[::1]:8090/client-metadata.json"),
+      ).toBeUndefined();
+    });
+
+    it("still requires a path on an exempt loopback host", () => {
+      expect(getCimdClientMetadataUrlError("http://localhost:8090")).toBe(
+        CIMD_METADATA_URL_PATH_ERROR,
+      );
+    });
+
+    it("accepts alternate spellings that canonicalize to the same addresses", () => {
+      // The check runs against the WHATWG-canonicalized `URL.hostname`, so the
+      // exemption covers these three *addresses*, not three input strings. Each
+      // of these reaches the loopback interface, which is what the security
+      // argument is about — and the SDK canonicalizes identically, so an alias
+      // accepted here is one it accepts for a token endpoint too.
+      for (const value of [
+        "http://127.1:8090/client-metadata.json",
+        "http://2130706433:8090/client-metadata.json",
+        "http://0x7f.0.0.1:8090/client-metadata.json",
+        "http://[0:0:0:0:0:0:0:1]:8090/client-metadata.json",
+        // WHATWG strips a root-anchored dot from an IP literal but not from a
+        // name, which is why this passes where `localhost.` below does not.
+        "http://127.0.0.1.:8090/client-metadata.json",
+        "http://LOCALHOST:8090/client-metadata.json",
+      ]) {
+        expect(getCimdClientMetadataUrlError(value)).toBeUndefined();
+      }
+    });
+
+    it("does not widen the exemption beyond those three addresses", () => {
+      // `localhost.` and `*.localhost` resolve to loopback on every resolver,
+      // but they are outside the SDK's allow-list — so they are outside ours
+      // too, deliberately, rather than the two disagreeing about one URL.
+      for (const value of [
+        "http://tenant.app.localhost:3300/client-metadata.json",
+        "http://127.0.0.2:8090/client-metadata.json",
+        "http://localhost.example.com/client-metadata.json",
+      ]) {
+        expect(getCimdClientMetadataUrlError(value)).toBe(
+          CIMD_METADATA_URL_HTTPS_ERROR,
+        );
+      }
+      // The root-anchored spelling never reaches the protocol check at all:
+      // `isAbsoluteHttpUrl` rejects its trailing empty label first, so it is
+      // flagged as unparseable rather than as non-HTTPS. Pinned so a later
+      // change to either check has to decide about this case on purpose.
+      expect(
+        getCimdClientMetadataUrlError("http://localhost./client-metadata.json"),
+      ).toBe(CIMD_METADATA_URL_INVALID_ERROR);
+    });
   });
 
   it("formatClientConfigLoadError summarizes Zod validation failures", () => {
diff --git a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts
index 96e03fc3..604ac63b 100644
--- a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts
+++ b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts
@@ -117,7 +117,7 @@ describe("RemoteClientTransport send contract", () => {
     const started = Date.now();
     await expect(
       transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
-    ).rejects.toThrow(/Timed out waiting for MCP response on SSE/i);
+    ).rejects.toThrow(/Request timed out/);
     expect(Date.now() - started).toBeLessThan(5000);
 
     await transport.close();
diff --git a/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts b/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts
new file mode 100644
index 00000000..d86a111c
--- /dev/null
+++ b/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts
@@ -0,0 +1,279 @@
+import { describe, it, expect } from "vitest";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
+import {
+  annotateRequestTimeout,
+  describeConnectionDiagnostics,
+  describeStreamEndpoint,
+  formatDuration,
+  isRequestTimeoutError,
+  MAX_LISTED_OUTSTANDING_REQUESTS,
+  type AnnotatedRequestTimeoutData,
+  type ConnectionDiagnostics,
+} from "@inspector/core/mcp/connectionDiagnostics.js";
+
+// A fixed clock so every duration in these assertions is exact.
+const NOW = 1_000_000;
+const seconds = (n: number) => n * 1000;
+
+/** The SDK's own per-request timeout, exactly as `Protocol.request` throws it. */
+function sdkTimeout(timeout = 60_000): SdkError {
+  return new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
+    timeout,
+  });
+}
+
+describe("formatDuration", () => {
+  it("renders sub-second values in milliseconds", () => {
+    expect(formatDuration(0)).toBe("0ms");
+    expect(formatDuration(850)).toBe("850ms");
+    expect(formatDuration(999.6)).toBe("1000ms");
+  });
+
+  it("renders seconds, minutes and hours at increasing granularity", () => {
+    expect(formatDuration(seconds(1))).toBe("1s");
+    expect(formatDuration(seconds(59))).toBe("59s");
+    expect(formatDuration(seconds(60))).toBe("1m00s");
+    expect(formatDuration(seconds(252))).toBe("4m12s");
+    expect(formatDuration(seconds(3600))).toBe("1h00m");
+    expect(formatDuration(seconds(3600 + 120 + 5))).toBe("1h02m");
+  });
+
+  it("clamps a negative value to zero rather than rendering nonsense", () => {
+    // A clock skew between two timestamps must not print `-3s`.
+    expect(formatDuration(-3000)).toBe("0ms");
+  });
+});
+
+describe("describeStreamEndpoint", () => {
+  it("reduces a stream URL to its method and path", () => {
+    expect(describeStreamEndpoint("https://host.example:8443/mcp?x=1")).toBe(
+      "GET /mcp",
+    );
+  });
+
+  it("falls back to the raw string for an unparseable URL", () => {
+    expect(describeStreamEndpoint("not a url")).toBe("GET not a url");
+  });
+});
+
+describe("describeConnectionDiagnostics", () => {
+  it("says so when nothing is outstanding and nothing has been received", () => {
+    expect(
+      describeConnectionDiagnostics({
+        capturedAt: NOW,
+        outstandingRequests: [],
+      }),
+    ).toBe(
+      "No requests are unanswered. No response has been received this session.",
+    );
+  });
+
+  it("names each unanswered request with how long ago it went out", () => {
+    const diagnostics: ConnectionDiagnostics = {
+      capturedAt: NOW,
+      outstandingRequests: [
+        { id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
+        { id: 3, method: "ping", sentAt: NOW - seconds(12) },
+      ],
+      lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
+    };
+    expect(describeConnectionDiagnostics(diagnostics)).toBe(
+      "2 requests are unanswered: tools/list (sent 1m00s ago), ping (sent 12s ago). " +
+        "Last response received 1m01s ago (initialize).",
+    );
+  });
+
+  it("uses the singular for exactly one unanswered request", () => {
+    const diagnostics: ConnectionDiagnostics = {
+      capturedAt: NOW,
+      outstandingRequests: [
+        { id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
+      ],
+    };
+    expect(describeConnectionDiagnostics(diagnostics)).toContain(
+      "1 request is unanswered: tools/list (sent 1m00s ago).",
+    );
+  });
+
+  it("collapses the tail of a long outstanding list into a count", () => {
+    const outstandingRequests = Array.from({ length: 8 }, (_, i) => ({
+      id: i,
+      method: `m${i}`,
+      sentAt: NOW - seconds(i + 1),
+    }));
+    const text = describeConnectionDiagnostics({
+      capturedAt: NOW,
+      outstandingRequests,
+    });
+    expect(text).toContain("8 requests are unanswered: ");
+    for (let i = 0; i < MAX_LISTED_OUTSTANDING_REQUESTS; i++) {
+      expect(text).toContain(`m${i} (sent`);
+    }
+    expect(text).not.toContain("m5 (sent");
+    expect(text).toContain(
+      `, and ${8 - MAX_LISTED_OUTSTANDING_REQUESTS} more.`,
+    );
+  });
+
+  it("describes an open notification stream by its endpoint, age and event count", () => {
+    const diagnostics: ConnectionDiagnostics = {
+      capturedAt: NOW,
+      outstandingRequests: [],
+      notificationStream: {
+        url: "http://127.0.0.1:9779/mcp",
+        openedAt: NOW - seconds(252),
+        eventCount: 0,
+      },
+    };
+    expect(describeConnectionDiagnostics(diagnostics)).toContain(
+      "Notification stream (GET /mcp) open for 4m12s, 0 events delivered.",
+    );
+  });
+
+  it("describes a closed notification stream by when it closed and how long it lasted", () => {
+    const diagnostics: ConnectionDiagnostics = {
+      capturedAt: NOW,
+      outstandingRequests: [],
+      notificationStream: {
+        url: "http://127.0.0.1:9779/mcp",
+        openedAt: NOW - seconds(185),
+        eventCount: 1,
+        closedAt: NOW - seconds(5),
+      },
+    };
+    expect(describeConnectionDiagnostics(diagnostics)).toContain(
+      "Notification stream (GET /mcp) closed 5s ago after 3m00s, 1 event delivered.",
+    );
+  });
+
+  it("measures every duration against the snapshot's own clock, not the wall clock", () => {
+    // A snapshot taken long ago still reads the way it read when it was
+    // taken — that is what makes a timeout message and a later render of the
+    // same snapshot agree.
+    const diagnostics: ConnectionDiagnostics = {
+      capturedAt: NOW,
+      outstandingRequests: [
+        { id: 1, method: "tools/list", sentAt: NOW - seconds(5) },
+      ],
+    };
+    expect(describeConnectionDiagnostics(diagnostics)).toBe(
+      "1 request is unanswered: tools/list (sent 5s ago). " +
+        "No response has been received this session.",
+    );
+  });
+});
+
+describe("isRequestTimeoutError", () => {
+  it("recognises the SDK's per-request timeout", () => {
+    expect(isRequestTimeoutError(sdkTimeout())).toBe(true);
+  });
+
+  it("rejects the other RequestTimeout-coded rejections, which are not timeouts", () => {
+    // An abort through `options.signal` with a non-SdkError reason: the SDK
+    // wraps it in the same code with no `{ timeout }` data.
+    expect(
+      isRequestTimeoutError(
+        new SdkError(SdkErrorCode.RequestTimeout, "AbortError: cancelled"),
+      ),
+    ).toBe(false);
+    // The max-total-timeout variant carries different data.
+    expect(
+      isRequestTimeoutError(
+        new SdkError(
+          SdkErrorCode.RequestTimeout,
+          "Maximum total timeout exceeded",
+          { maxTotalTimeout: 1000, totalElapsed: 1200 },
+        ),
+      ),
+    ).toBe(false);
+    // A non-numeric `timeout` is not the SDK's shape either.
+    expect(
+      isRequestTimeoutError(
+        new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
+          timeout: "60s",
+        }),
+      ),
+    ).toBe(false);
+  });
+
+  it("rejects other SdkErrors, plain errors and non-errors", () => {
+    expect(
+      isRequestTimeoutError(
+        new SdkError(SdkErrorCode.InvalidResult, "bad", { timeout: 1 }),
+      ),
+    ).toBe(false);
+    expect(isRequestTimeoutError(new Error("Request timed out"))).toBe(false);
+    expect(isRequestTimeoutError("Request timed out")).toBe(false);
+    expect(isRequestTimeoutError(null)).toBe(false);
+  });
+});
+
+describe("annotateRequestTimeout", () => {
+  const diagnostics: ConnectionDiagnostics = {
+    capturedAt: NOW,
+    outstandingRequests: [
+      { id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
+    ],
+    lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
+    notificationStream: {
+      url: "http://127.0.0.1:9779/mcp",
+      openedAt: NOW - seconds(62),
+      eventCount: 0,
+    },
+  };
+
+  it("rewrites the message to name the request, its budget and the connection state", () => {
+    const annotated = annotateRequestTimeout(
+      sdkTimeout(60_000),
+      "tools/list",
+      diagnostics,
+    );
+    expect(SdkError.isInstance(annotated)).toBe(true);
+    expect((annotated as SdkError).message).toBe(
+      "Request timed out after 1m00s (tools/list). " +
+        "1 request is unanswered: tools/list (sent 1m00s ago). " +
+        "Last response received 1m01s ago (initialize). " +
+        "Notification stream (GET /mcp) open for 1m02s, 0 events delivered.",
+    );
+  });
+
+  it("keeps the code, carries the method and snapshot in data, and links the original as cause", () => {
+    const original = sdkTimeout(300);
+    const annotated = annotateRequestTimeout(
+      original,
+      "ping",
+      diagnostics,
+    ) as SdkError;
+    expect(annotated).not.toBe(original);
+    expect(annotated.code).toBe(SdkErrorCode.RequestTimeout);
+    expect(annotated.cause).toBe(original);
+    const data = annotated.data as AnnotatedRequestTimeoutData;
+    expect(data.timeout).toBe(300);
+    expect(data.method).toBe("ping");
+    expect(data.diagnostics).toBe(diagnostics);
+    // The rebuilt error still satisfies the predicate, so a consumer that
+    // checks before and after this runs sees the same answer.
+    expect(isRequestTimeoutError(annotated)).toBe(true);
+  });
+
+  it("returns anything that is not the per-request timeout untouched", () => {
+    const plain = new Error("boom");
+    expect(annotateRequestTimeout(plain, "ping", diagnostics)).toBe(plain);
+    const abort = new SdkError(SdkErrorCode.RequestTimeout, "cancelled");
+    expect(annotateRequestTimeout(abort, "ping", diagnostics)).toBe(abort);
+    expect(annotateRequestTimeout(undefined, "ping", diagnostics)).toBe(
+      undefined,
+    );
+  });
+
+  it("reads cleanly when nothing else is known about the connection", () => {
+    const annotated = annotateRequestTimeout(sdkTimeout(1000), "ping", {
+      capturedAt: NOW,
+      outstandingRequests: [],
+    }) as SdkError;
+    expect(annotated.message).toBe(
+      "Request timed out after 1s (ping). No requests are unanswered. " +
+        "No response has been received this session.",
+    );
+  });
+});
diff --git a/clients/web/src/test/core/mcp/extensions.test.ts b/clients/web/src/test/core/mcp/extensions.test.ts
index 5a2bbb26..5f25108d 100644
--- a/clients/web/src/test/core/mcp/extensions.test.ts
+++ b/clients/web/src/test/core/mcp/extensions.test.ts
@@ -7,12 +7,21 @@ import {
   buildClientExtensions,
 } from "@inspector/core/mcp/extensions.js";
 import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
+import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
 
 // The `ui` extension carries a non-empty advertisement value; the others are
 // declared with `{}`. Spelled out here so the map assertions stay readable.
 const UI_ADVERTISEMENT = { mimeTypes: [MCP_APP_MIME_TYPE] };
 
-describe("extensions (#1738, #1740)", () => {
+// Every registry entry switched off, so a test can re-enable exactly the ones
+// it is about without restating the whole registry.
+const ALL_REGISTRY_OFF = {
+  [TASKS_EXTENSION_KEY]: false,
+  [UI_EXTENSION_KEY]: false,
+  [SKILLS_EXTENSION_KEY]: false,
+};
+
+describe("extensions (#1738, #1740, #2373)", () => {
   describe("ADVERTISABLE_EXTENSIONS registry", () => {
     it("lists the Tasks extension, advertised by default", () => {
       const tasks = ADVERTISABLE_EXTENSIONS.find(
@@ -37,6 +46,19 @@ describe("extensions (#1738, #1740)", () => {
       expect(typeof MCP_APP_MIME_TYPE).toBe("string");
     });
 
+    it("lists the Skills extension, advertised by default with no settings (#2373)", () => {
+      // SEP-2133 negotiates an extension from both sides, so a server may
+      // refuse `skills/*` to a client that did not declare it. The Inspector
+      // calls those methods, so it must declare the extension by default.
+      const skills = ADVERTISABLE_EXTENSIONS.find(
+        (e) => e.key === SKILLS_EXTENSION_KEY,
+      );
+      expect(skills).toBeDefined();
+      expect(skills?.defaultAdvertised).toBe(true);
+      expect(skills?.advertisement).toBeUndefined();
+      expect(skills?.label).toContain("Skills");
+    });
+
     it("does not list EMA (it follows the auth mode, not a toggle)", () => {
       expect(
         ADVERTISABLE_EXTENSIONS.some((e) => e.key === EMA_EXTENSION_KEY),
@@ -53,11 +75,12 @@ describe("extensions (#1738, #1740)", () => {
   });
 
   describe("buildClientExtensions", () => {
-    it("advertises registry defaults with no overrides (tasks + ui)", () => {
+    it("advertises registry defaults with no overrides (tasks + ui + skills)", () => {
       const map = buildClientExtensions({ enterpriseManaged: false });
       expect(map).toEqual({
         [TASKS_EXTENSION_KEY]: {},
         [UI_EXTENSION_KEY]: UI_ADVERTISEMENT,
+        [SKILLS_EXTENSION_KEY]: {},
       });
     });
 
@@ -82,6 +105,7 @@ describe("extensions (#1738, #1740)", () => {
       expect(map).toEqual({
         [TASKS_EXTENSION_KEY]: {},
         [UI_EXTENSION_KEY]: UI_ADVERTISEMENT,
+        [SKILLS_EXTENSION_KEY]: {},
         [EMA_EXTENSION_KEY]: {},
       });
     });
@@ -94,23 +118,39 @@ describe("extensions (#1738, #1740)", () => {
     it("honors a user override that disables a default-on extension", () => {
       const map = buildClientExtensions({
         enterpriseManaged: false,
-        advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false },
+        advertised: ALL_REGISTRY_OFF,
       });
       expect(map).toEqual({});
     });
 
-    it("can disable just the UI extension, keeping Tasks (#1740)", () => {
+    it("can disable just the UI extension, keeping the others (#1740)", () => {
       const map = buildClientExtensions({
         enterpriseManaged: false,
         advertised: { [UI_EXTENSION_KEY]: false },
       });
-      expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} });
+      expect(map).toEqual({
+        [TASKS_EXTENSION_KEY]: {},
+        [SKILLS_EXTENSION_KEY]: {},
+      });
+    });
+
+    it("can disable just the Skills extension, keeping the others (#2373)", () => {
+      // The Server Settings toggle for skills is how a user checks that a
+      // server refuses `skills/*` to a client that did not declare it.
+      const map = buildClientExtensions({
+        enterpriseManaged: false,
+        advertised: { [SKILLS_EXTENSION_KEY]: false },
+      });
+      expect(map).toEqual({
+        [TASKS_EXTENSION_KEY]: {},
+        [UI_EXTENSION_KEY]: UI_ADVERTISEMENT,
+      });
     });
 
     it("honors a user override that keeps a default-on extension enabled", () => {
       const map = buildClientExtensions({
         enterpriseManaged: false,
-        advertised: { [TASKS_EXTENSION_KEY]: true, [UI_EXTENSION_KEY]: false },
+        advertised: { ...ALL_REGISTRY_OFF, [TASKS_EXTENSION_KEY]: true },
       });
       expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} });
     });
@@ -134,13 +174,14 @@ describe("extensions (#1738, #1740)", () => {
       expect(map).toEqual({
         [TASKS_EXTENSION_KEY]: {},
         [UI_EXTENSION_KEY]: UI_ADVERTISEMENT,
+        [SKILLS_EXTENSION_KEY]: {},
       });
     });
 
     it("layers EMA on even when all registry entries are disabled", () => {
       const map = buildClientExtensions({
         enterpriseManaged: true,
-        advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false },
+        advertised: ALL_REGISTRY_OFF,
       });
       expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} });
     });
@@ -163,7 +204,11 @@ describe("extensions (#1738, #1740)", () => {
       });
       // A nested setting, NOT a second extension — the contract is explicit
       // that no new extension id is introduced.
-      expect(Object.keys(map)).toEqual([TASKS_EXTENSION_KEY, UI_EXTENSION_KEY]);
+      expect(Object.keys(map)).toEqual([
+        TASKS_EXTENSION_KEY,
+        UI_EXTENSION_KEY,
+        SKILLS_EXTENSION_KEY,
+      ]);
     });
 
     it("advertises nothing when the UI extension itself is turned off", () => {
diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts
index 76abcb0f..af1e58b9 100644
--- a/clients/web/src/test/core/mcp/fetchTracking.test.ts
+++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts
@@ -1,13 +1,21 @@
 import { describe, it, expect, vi } from "vitest";
 import {
+  CHALLENGE_BODY_MAX_BYTES,
+  CHALLENGE_BODY_READ_MS,
+  TRUNCATED_BODY_SUFFIX,
+  WITHHELD_TRUNCATED_JSON_BODY,
   createFetchTracker,
+  findHeader,
   redactSensitiveHeaders,
   redactBody,
   redactUrlQuery,
   REDACTED_HEADER_VALUE,
   REDACTED_VALUE,
 } from "@inspector/core/mcp/fetchTracking.js";
-import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
+import type {
+  FetchRequestEntryBase,
+  FetchStreamState,
+} from "@inspector/core/mcp/types.js";
 
 // The tracker fires `trackRequest` synchronously with an entry whose
 // responseBody is always undefined, then reads the body in the background
@@ -539,6 +547,281 @@ describe("createFetchTracker", () => {
   });
 });
 
+describe("createFetchTracker — 401/403 challenge bodies (#2297)", () => {
+  function track(response: Response) {
+    const tracked: FetchRequestEntryBase[] = [];
+    const updateResponseBody = vi.fn();
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      trackRequest: (entry) => tracked.push(entry),
+      updateResponseBody,
+    });
+    return { fetcher, tracked, updateResponseBody };
+  }
+
+  it("records the body on the entry itself, before returning", async () => {
+    const body = JSON.stringify({ error: "invalid_token" });
+    const { fetcher, tracked, updateResponseBody } = track(
+      new Response(body, {
+        status: 401,
+        headers: { "content-type": "application/json" },
+      }),
+    );
+
+    const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
+
+    expect(tracked).toHaveLength(1);
+    expect(tracked[0]?.responseStatus).toBe(401);
+    expect(tracked[0]?.responseBody).toBe(body);
+    expect(updateResponseBody).not.toHaveBeenCalled();
+    // The caller still gets an unread body.
+    expect(await res.text()).toBe(body);
+  });
+
+  it("does the same for a 403", async () => {
+    const { fetcher, tracked } = track(
+      new Response("insufficient_scope", { status: 403 }),
+    );
+    await fetcher("https://mcp.example/mcp", { method: "POST" });
+    expect(tracked[0]?.responseBody).toBe("insufficient_scope");
+  });
+
+  it("stops at the byte cap and marks the body truncated", async () => {
+    const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES / 2).fill(97);
+    let pulls = 0;
+    const cancel = vi.fn();
+    const stream = new ReadableStream({
+      pull(controller) {
+        pulls += 1;
+        controller.enqueue(chunk);
+      },
+      cancel,
+    });
+    const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
+
+    const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
+
+    const recorded = tracked[0]?.responseBody ?? "";
+    expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
+    expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe(
+      CHALLENGE_BODY_MAX_BYTES,
+    );
+    // With the clone released, cancelling the caller's branch reaches the source.
+    await res.body?.cancel();
+    expect(cancel).toHaveBeenCalled();
+    expect(pulls).toBeLessThan(10);
+  });
+
+  it("gives up on a stalled body at the deadline and releases the source", async () => {
+    vi.useFakeTimers();
+    try {
+      const cancel = vi.fn();
+      const stream = new ReadableStream({
+        start(controller) {
+          controller.enqueue(new TextEncoder().encode("partial"));
+        },
+        cancel,
+      });
+      const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
+
+      const pending = fetcher("https://mcp.example/mcp", { method: "POST" });
+      await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS);
+      const res = await pending;
+
+      expect(tracked[0]?.responseBody).toBe(`partial${TRUNCATED_BODY_SUFFIX}`);
+      await res.body?.cancel();
+      expect(cancel).toHaveBeenCalled();
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it("records the entry with no body when the clone cannot be made", async () => {
+    const response = new Response("nope", { status: 401 });
+    vi.spyOn(response, "clone").mockImplementation(() => {
+      throw new Error("clone failed");
+    });
+    const { fetcher, tracked } = track(response);
+
+    await fetcher("https://mcp.example/mcp", { method: "POST" });
+
+    expect(tracked).toHaveLength(1);
+    expect(tracked[0]?.responseStatus).toBe(401);
+    expect(tracked[0]?.responseBody).toBeUndefined();
+  });
+
+  it("records the entry with no body when the read errors", async () => {
+    const stream = new ReadableStream({
+      pull(controller) {
+        controller.error(new Error("socket reset"));
+      },
+    });
+    const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
+
+    await fetcher("https://mcp.example/mcp", { method: "POST" });
+
+    expect(tracked[0]?.responseStatus).toBe(401);
+    expect(tracked[0]?.responseBody).toBeUndefined();
+  });
+});
+
+describe("createFetchTracker — challenge body bounds and redaction (#2297)", () => {
+  function trackOne(response: Response) {
+    const tracked: FetchRequestEntryBase[] = [];
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      trackRequest: (entry) => tracked.push(entry),
+    });
+    return { fetcher, tracked };
+  }
+
+  /** A body that delivers `prefix` and then never ends. */
+  function stalledBody(prefix: string): ReadableStream {
+    return new ReadableStream({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode(prefix));
+      },
+    });
+  }
+
+  async function withDeadlineElapsed(run: () => Promise): Promise {
+    vi.useFakeTimers();
+    try {
+      const pending = run();
+      await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS);
+      return await pending;
+    } finally {
+      vi.useRealTimers();
+    }
+  }
+
+  it("caps a single chunk larger than the budget at the byte limit", async () => {
+    const oversized = new Uint8Array(1024 * 1024).fill(97);
+    const { fetcher, tracked } = trackOne(
+      new Response(
+        new ReadableStream({
+          start(controller) {
+            controller.enqueue(oversized);
+            controller.close();
+          },
+        }),
+        { status: 401 },
+      ),
+    );
+
+    await fetcher("https://mcp.example/mcp", { method: "POST" });
+
+    const recorded = tracked[0]?.responseBody ?? "";
+    expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
+    expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe(
+      CHALLENGE_BODY_MAX_BYTES,
+    );
+  });
+
+  it("records the body of a 401 to a long-lived-stream GET", async () => {
+    const { fetcher, tracked } = trackOne(
+      new Response("unauthorized", {
+        status: 401,
+        headers: { "content-type": "text/event-stream" },
+      }),
+    );
+
+    await fetcher("https://mcp.example/mcp", { method: "GET" });
+
+    expect(tracked[0]?.responseBody).toBe("unauthorized");
+  });
+
+  it("withholds a cut-off JSON body rather than logging an unredacted prefix", async () => {
+    const { fetcher, tracked } = trackOne(
+      new Response(stalledBody('{"access_token":"live-access","token_type'), {
+        status: 401,
+        headers: { "content-type": "application/json" },
+      }),
+    );
+
+    await withDeadlineElapsed(() =>
+      fetcher("https://mcp.example/token", { method: "POST" }),
+    );
+
+    expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY);
+    expect(serializedWithoutId(tracked[0]!)).not.toContain("live-access");
+  });
+
+  it("withholds a cut-off body that sniffs as JSON with no content-type", async () => {
+    const { fetcher, tracked } = trackOne(
+      new Response(stalledBody('  [{"refresh_token":"live-refresh"'), {
+        status: 403,
+      }),
+    );
+
+    await withDeadlineElapsed(() =>
+      fetcher("https://mcp.example/token", { method: "POST" }),
+    );
+
+    expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY);
+  });
+
+  it("still redacts a complete JSON challenge body", async () => {
+    const { fetcher, tracked } = trackOne(
+      new Response(JSON.stringify({ error: "x", access_token: "live" }), {
+        status: 401,
+        headers: { "content-type": "application/json" },
+      }),
+    );
+
+    await fetcher("https://mcp.example/token", { method: "POST" });
+
+    expect(tracked[0]?.responseBody).toBe(
+      JSON.stringify({ error: "x", access_token: REDACTED_VALUE }),
+    );
+  });
+
+  it("redacts a cut-off form body field by field", async () => {
+    const { fetcher, tracked } = trackOne(
+      new Response(stalledBody("error=invalid&access_token=live&scope=re"), {
+        status: 401,
+        headers: { "content-type": "application/x-www-form-urlencoded" },
+      }),
+    );
+
+    await withDeadlineElapsed(() =>
+      fetcher("https://mcp.example/token", { method: "POST" }),
+    );
+
+    const recorded = tracked[0]?.responseBody ?? "";
+    expect(recorded).not.toContain("live");
+    expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
+  });
+});
+
+describe("createFetchTracker — challenge body cancel rejection (#2297)", () => {
+  it("swallows the source's cancel() rejection after stopping early", async () => {
+    const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES).fill(97);
+    const stream = new ReadableStream({
+      pull(controller) {
+        controller.enqueue(chunk);
+      },
+      cancel() {
+        throw new Error("cancel failed");
+      },
+    });
+    const tracked: FetchRequestEntryBase[] = [];
+    const fetcher = createFetchTracker(
+      (async () => new Response(stream, { status: 401 })) as typeof fetch,
+      { trackRequest: (entry) => tracked.push(entry) },
+    );
+
+    const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
+    // Cancelling the caller's branch cancels the source, whose rejection then
+    // reaches the tracker's own cancel of its clone — which must swallow it.
+    await res.body?.cancel().catch(() => undefined);
+    await flush();
+
+    expect(tracked[0]?.responseStatus).toBe(401);
+    expect(tracked[0]?.responseBody?.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(
+      true,
+    );
+  });
+});
+
 describe("redactUrlQuery", () => {
   it("redacts sensitive params and keeps the path + other params", () => {
     expect(
@@ -696,3 +979,389 @@ describe("redactSensitiveHeaders", () => {
     expect(input.authorization).toBe("Bearer x");
   });
 });
+
+describe("findHeader", () => {
+  it("looks a header up case-insensitively and tolerates a missing record", () => {
+    expect(findHeader({ "Content-Type": "text/plain" }, "content-type")).toBe(
+      "text/plain",
+    );
+    expect(findHeader({ "content-type": "text/plain" }, "CONTENT-TYPE")).toBe(
+      "text/plain",
+    );
+    expect(findHeader({ accept: "*/*" }, "content-type")).toBeUndefined();
+    expect(findHeader(undefined, "content-type")).toBeUndefined();
+  });
+});
+
+describe("createFetchTracker long-lived stream watching (#2318)", () => {
+  const encoder = new TextEncoder();
+
+  /**
+   * A server-controlled SSE body: `push` writes a chunk, `end` closes it, and
+   * `fail` errors it — the three ways a real stream advances.
+   */
+  function controlledStream() {
+    let controller!: ReadableStreamDefaultController;
+    const stream = new ReadableStream({
+      start(c) {
+        controller = c;
+      },
+    });
+    return {
+      stream,
+      push: (text: string) => controller.enqueue(encoder.encode(text)),
+      end: () => controller.close(),
+      fail: (err: Error) => controller.error(err),
+    };
+  }
+
+  /** Track a GET whose response is the given stream, collecting stream updates. */
+  async function trackStream(stream: ReadableStream) {
+    const updates: Array<{ id: string; stream: FetchStreamState }> = [];
+    const tracked: FetchRequestEntryBase[] = [];
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response(stream, {
+          headers: { "content-type": "text/event-stream" },
+        })) as typeof fetch,
+      {
+        trackRequest: (entry) => tracked.push(entry),
+        updateStream: (id, state) => updates.push({ id, stream: state }),
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    const response = await fetcher("https://example.com/mcp", {
+      method: "GET",
+    });
+    return { response, updates, id: tracked[0]!.id };
+  }
+
+  it("announces the stream as open with zero events as soon as it is watched", async () => {
+    const server = controlledStream();
+    const { updates, id } = await trackStream(server.stream);
+    // Before a byte arrives — this is the state the #2187 stall shows.
+    expect(updates).toEqual([{ id, stream: { eventCount: 0 } }]);
+  });
+
+  it("counts each data-carrying SSE event as it is delivered", async () => {
+    const server = controlledStream();
+    const { updates, id } = await trackStream(server.stream);
+    server.push('event: message\ndata: {"jsonrpc":"2.0","method":"a"}\n\n');
+    await flush();
+    expect(updates.at(-1)).toEqual({ id, stream: { eventCount: 1 } });
+    // An event split across chunks is still one event.
+    server.push('data: {"jsonrpc"');
+    server.push(':"2.0","method":"b"}\n');
+    await flush();
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1]);
+    server.push("\n");
+    await flush();
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
+  });
+
+  it("ignores keepalive comments and blocks with no data field, and accepts a bare `data` line and CRLF framing", async () => {
+    const server = controlledStream();
+    const { updates } = await trackStream(server.stream);
+    // A comment-only block (the keepalive shape) and an id-only block are
+    // not events; a bare `data` (no colon) is; so is a CRLF-delimited block.
+    server.push(": keepalive\n\n");
+    server.push("id: 7\n\n");
+    server.push("data\n\n");
+    server.push("data: x\r\n\r\n");
+    await flush();
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
+  });
+
+  it("counts every non-blank line on an NDJSON stream", async () => {
+    const server = controlledStream();
+    const updates: Array<{ id: string; stream: FetchStreamState }> = [];
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response(server.stream, {
+          headers: { "content-type": "application/x-ndjson" },
+        })) as typeof fetch,
+      {
+        updateStream: (id, state) => updates.push({ id, stream: state }),
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    await fetcher("https://example.com/mcp", { method: "GET" });
+    server.push('{"jsonrpc":"2.0","method":"a"}\n\n');
+    server.push('{"jsonrpc":"2.0","method":"b"}\r\n');
+    // A partial line is not an event until its newline arrives.
+    server.push('{"jsonrpc":"2.0",');
+    await flush();
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
+    server.push('"method":"c"}\n');
+    await flush();
+    expect(updates.at(-1)!.stream.eventCount).toBe(3);
+  });
+
+  it("drops a partial SSE block at end-of-stream, as the SDK's parser does", async () => {
+    const server = controlledStream();
+    const { updates } = await trackStream(server.stream);
+    // A data line with no dispatching blank line before the stream ends.
+    server.push("data: one\n\ndata: two\n");
+    server.end();
+    await flush();
+    const last = updates.at(-1)!.stream;
+    expect(last.eventCount).toBe(1);
+    expect(last.closedAt).toBeInstanceOf(Date);
+  });
+
+  it("accepts bare CR line endings, and a CRLF split across chunks", async () => {
+    const server = controlledStream();
+    const { updates } = await trackStream(server.stream);
+    server.push("data: a\r\rdata: b\r");
+    await flush();
+    // The trailing CR is held: it may be the first half of a CRLF.
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1]);
+    server.push("\n\r\n");
+    await flush();
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
+  });
+
+  it("classifies the content type and the method case-insensitively", async () => {
+    const server = controlledStream();
+    const updates: unknown[] = [];
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response(server.stream, {
+          headers: { "content-type": "Text/Event-Stream" },
+        })) as typeof fetch,
+      {
+        updateStream: (id, state) => updates.push({ id, state }),
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    await fetcher("https://example.com/mcp", { method: "get" });
+    expect(updates).toHaveLength(1);
+  });
+
+  it("takes the method from a Request input when init carries none", async () => {
+    // A bounded POST reply must go down the body-capture path, not the
+    // watcher, when the caller passed a `Request` rather than `init`.
+    const bodies: string[] = [];
+    const updates: unknown[] = [];
+    const tracked: FetchRequestEntryBase[] = [];
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response("data: x\n\n", {
+          headers: { "content-type": "text/event-stream" },
+        })) as typeof fetch,
+      {
+        trackRequest: (entry) => tracked.push(entry),
+        updateResponseBody: (_id, body) => bodies.push(body),
+        updateStream: (id, state) => updates.push({ id, state }),
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    await fetcher(new Request("https://example.com/mcp", { method: "POST" }));
+    await flush();
+    expect(tracked[0]?.method).toBe("POST");
+    expect(bodies).toEqual(["data: x\n\n"]);
+    expect(updates).toEqual([]);
+  });
+
+  it("coalesces a moving count to one report per interval, and reports the close at once", async () => {
+    vi.useFakeTimers();
+    try {
+      const server = controlledStream();
+      const updates: Array<{ id: string; stream: FetchStreamState }> = [];
+      const fetcher = createFetchTracker(
+        (async () =>
+          new Response(server.stream, {
+            headers: { "content-type": "text/event-stream" },
+          })) as typeof fetch,
+        {
+          updateStream: (id, state) => updates.push({ id, stream: state }),
+          streamUpdateIntervalMs: 100,
+        },
+      );
+      await fetcher("https://example.com/mcp", { method: "GET" });
+      // The open is immediate.
+      expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
+      server.push("data: a\n\ndata: b\n\ndata: c\n\n");
+      await vi.advanceTimersByTimeAsync(0);
+      // Three events, no report yet — they are inside one window.
+      expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
+      await vi.advanceTimersByTimeAsync(100);
+      expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3]);
+      // A close cancels a pending window and reports immediately.
+      server.push("data: d\n\n");
+      server.end();
+      await vi.advanceTimersByTimeAsync(0);
+      expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3, 4]);
+      expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date);
+      await vi.advanceTimersByTimeAsync(200);
+      expect(updates).toHaveLength(3);
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it("counts an event whose bare-CR terminator is the last byte of the stream", async () => {
+    const server = controlledStream();
+    const { updates } = await trackStream(server.stream);
+    server.push("data: x\r\r");
+    await flush();
+    // Held while the stream is open: the CR could be half of a CRLF.
+    expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
+    server.end();
+    await flush();
+    expect(updates.at(-1)!.stream).toMatchObject({ eventCount: 1 });
+    expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date);
+  });
+
+  it("returns the response even when the listener throws on the open report", async () => {
+    // The open is reported synchronously inside the fetch wrapper; a throw
+    // there must not turn a successful response into a rejected fetch.
+    const server = controlledStream();
+    const response = new Response(server.stream, {
+      headers: { "content-type": "text/event-stream" },
+    });
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      updateStream: () => {
+        throw new Error("sink closed");
+      },
+      streamUpdateIntervalMs: 0,
+    });
+    await expect(
+      fetcher("https://example.com/mcp", { method: "GET" }),
+    ).resolves.toBe(response);
+    // ...and a throw from a coalesced count does not escape the timer either.
+    server.push("data: a\n\n");
+    server.end();
+    await flush();
+  });
+
+  it("survives a stream-update listener that throws on the close", async () => {
+    // The watcher's promise is discarded, so a throw from the final report
+    // would otherwise be an unhandled rejection — which fails the whole run.
+    const server = controlledStream();
+    let reports = 0;
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response(server.stream, {
+          headers: { "content-type": "text/event-stream" },
+        })) as typeof fetch,
+      {
+        updateStream: (_id, state) => {
+          reports += 1;
+          if (state.closedAt) throw new Error("listener exploded");
+        },
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    await fetcher("https://example.com/mcp", { method: "GET" });
+    server.end();
+    await flush();
+    expect(reports).toBe(2);
+  });
+
+  it("reports the close when the server ends the stream", async () => {
+    const server = controlledStream();
+    const { updates, id } = await trackStream(server.stream);
+    server.push("data: one\n\n");
+    server.end();
+    await flush();
+    expect(updates).toHaveLength(3);
+    expect(updates[2]!.id).toBe(id);
+    expect(updates[2]!.stream.eventCount).toBe(1);
+    expect(updates[2]!.stream.closedAt).toBeInstanceOf(Date);
+    // Only the close carries `closedAt`.
+    expect(updates[0]!.stream.closedAt).toBeUndefined();
+    expect(updates[1]!.stream.closedAt).toBeUndefined();
+  });
+
+  it("reports the close when the stream errors, with the count so far", async () => {
+    const server = controlledStream();
+    const { updates } = await trackStream(server.stream);
+    server.push("data: one\n\n");
+    server.fail(new Error("connection reset"));
+    await flush();
+    expect(updates).toHaveLength(3);
+    expect(updates[2]!.stream).toMatchObject({ eventCount: 1 });
+    expect(updates[2]!.stream.closedAt).toBeInstanceOf(Date);
+  });
+
+  it("leaves the transport's own copy of the stream intact", async () => {
+    const server = controlledStream();
+    const { response, updates } = await trackStream(server.stream);
+    server.push("data: hello\n\n");
+    server.end();
+    // The transport reads the original; the watcher read a tee'd clone.
+    expect(await response.text()).toBe("data: hello\n\n");
+    await flush();
+    expect(updates.at(-1)!.stream).toMatchObject({ eventCount: 1 });
+  });
+
+  it("does not clone the response when nobody subscribed to stream updates", async () => {
+    const response = new Response(controlledStream().stream, {
+      headers: { "content-type": "text/event-stream" },
+    });
+    const clone = vi.spyOn(response, "clone");
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      trackRequest: () => {},
+    });
+    await fetcher("https://example.com/mcp", { method: "GET" });
+    expect(clone).not.toHaveBeenCalled();
+  });
+
+  it("does not watch a bounded response", async () => {
+    const updates: unknown[] = [];
+    const fetcher = createFetchTracker(
+      (async () =>
+        new Response("data: x\n\n", {
+          headers: { "content-type": "text/event-stream" },
+        })) as typeof fetch,
+      {
+        updateStream: (id, state) => updates.push({ id, state }),
+        streamUpdateIntervalMs: 0,
+      },
+    );
+    // A POST SSE reply is bounded and goes down the body-capture path.
+    await fetcher("https://example.com/mcp", { method: "POST" });
+    await flush();
+    expect(updates).toEqual([]);
+  });
+
+  it("gives up quietly when the response cannot be cloned", async () => {
+    const response = new Response(controlledStream().stream, {
+      headers: { "content-type": "text/event-stream" },
+    });
+    vi.spyOn(response, "clone").mockImplementation(() => {
+      throw new TypeError("already consumed");
+    });
+    const updates: unknown[] = [];
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      updateStream: (id, state) => updates.push({ id, state }),
+      streamUpdateIntervalMs: 0,
+    });
+    await expect(
+      fetcher("https://example.com/mcp", { method: "GET" }),
+    ).resolves.toBe(response);
+    await flush();
+    expect(updates).toEqual([]);
+  });
+
+  it("gives up quietly when the clone has no body", async () => {
+    const response = new Response(controlledStream().stream, {
+      headers: { "content-type": "text/event-stream" },
+    });
+    vi.spyOn(response, "clone").mockImplementation(
+      () =>
+        new Response(null, {
+          headers: { "content-type": "text/event-stream" },
+        }),
+    );
+    const updates: unknown[] = [];
+    const fetcher = createFetchTracker((async () => response) as typeof fetch, {
+      updateStream: (id, state) => updates.push({ id, state }),
+      streamUpdateIntervalMs: 0,
+    });
+    await fetcher("https://example.com/mcp", { method: "GET" });
+    await flush();
+    expect(updates).toEqual([]);
+  });
+});
diff --git a/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts
new file mode 100644
index 00000000..35adb3c6
--- /dev/null
+++ b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from "vitest";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
+import type { JSONRPCRequest } from "@modelcontextprotocol/client";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import type { MessageTrackingCallbacks } from "@inspector/core/mcp/messageTrackingTransport.js";
+
+/**
+ * The outstanding-request bookkeeping behind the connection diagnostics
+ * (#2318), driven through the message-tracking callbacks the client hands its
+ * transport — the same seam the transport uses, without a connection.
+ */
+describe("InspectorClient diagnostics tracking callbacks", () => {
+  interface Internals {
+    createMessageTrackingCallbacks: () => MessageTrackingCallbacks;
+  }
+
+  function makeClient(): {
+    client: InspectorClient;
+    callbacks: MessageTrackingCallbacks;
+  } {
+    const client = new InspectorClient(
+      { type: "stdio", command: "noop", args: [] },
+      // Never connects; the callbacks are exercised directly.
+      { environment: { transport: () => ({}) as never } },
+    );
+    // `createMessageTrackingCallbacks` is private: it is the seam the client
+    // hands its transport, and driving it directly is what lets this test
+    // exercise the bookkeeping without a connection. The double cast is the
+    // documented test-only exception for reaching a private member — the
+    // same one `inspectorClient-raw-wire.test.ts` uses — and `Internals`
+    // names exactly the one method it reaches.
+    const callbacks = (
+      client as unknown as Internals
+    ).createMessageTrackingCallbacks();
+    return { client, callbacks };
+  }
+
+  const request = (id: number): JSONRPCRequest => ({
+    jsonrpc: "2.0",
+    id,
+    method: "tools/list",
+  });
+
+  const outstandingIds = (client: InspectorClient) =>
+    client.getConnectionDiagnostics().outstandingRequests.map((r) => r.id);
+
+  it("rolls a request back when its send failed before reaching the wire", () => {
+    const { client, callbacks } = makeClient();
+    callbacks.trackRequest!(request(1), "client");
+    expect(outstandingIds(client)).toEqual([1]);
+    callbacks.trackSendFailure!(request(1), new Error("connection closed"));
+    expect(outstandingIds(client)).toEqual([]);
+  });
+
+  it("keeps a request whose send failed with a request timeout", () => {
+    // The browser's remote transport awaits the response inside `send`, so
+    // its relay timeout surfaces as a send failure — the request reached the
+    // server and is still unanswered.
+    const { client, callbacks } = makeClient();
+    callbacks.trackRequest!(request(1), "client");
+    callbacks.trackSendFailure!(
+      request(1),
+      new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
+        timeout: 60_000,
+      }),
+    );
+    expect(outstandingIds(client)).toEqual([1]);
+  });
+
+  it("only tracks client-originated requests, and ignores a rollback for an unknown id", () => {
+    const { client, callbacks } = makeClient();
+    callbacks.trackRequest!(request(7), "server");
+    expect(outstandingIds(client)).toEqual([]);
+    let changes = 0;
+    client.addEventListener("connectionDiagnosticsChange", () => {
+      changes += 1;
+    });
+    callbacks.trackSendFailure!(request(7), new Error("nope"));
+    expect(changes).toBe(0);
+  });
+});
diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts
index 2d9af6b1..638891b5 100644
--- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts
+++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts
@@ -1,4 +1,5 @@
 import { describe, it, expect, vi } from "vitest";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
 import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
 import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js";
 
@@ -134,7 +135,15 @@ describe("InspectorClient raw-wire channel (#1631)", () => {
         {},
         ModernGetTaskResultSchema,
       );
-      const assertion = expect(promise).rejects.toThrow(/timed out/);
+      // The same annotated shape an SDK request's timeout carries (#2318):
+      // the SDK's code, the budget, the method, and the connection state.
+      const assertion = expect(promise).rejects.toMatchObject({
+        code: SdkErrorCode.RequestTimeout,
+        message: expect.stringMatching(
+          /^Request timed out after 10ms \(tasks\/get\)\. No requests are unanswered\./,
+        ),
+        data: expect.objectContaining({ timeout: 10, method: "tasks/get" }),
+      });
       await vi.advanceTimersByTimeAsync(20);
       await assertion;
     } finally {
@@ -142,6 +151,46 @@ describe("InspectorClient raw-wire channel (#1631)", () => {
     }
   });
 
+  it("annotates a request timeout the transport's send rejects with (#2318)", async () => {
+    // The browser's remote transport awaits the response inside `send`, so
+    // its relay wait can expire before the local timer; it arrives here as
+    // the SDK's timeout shape and gets the same annotation.
+    const client = makeClient();
+    internals(client).transport = {
+      send: vi.fn().mockRejectedValue(
+        new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
+          timeout: 60_000,
+        }),
+      ),
+    };
+    await expect(
+      internals(client).rawWireRequest(
+        "tasks/get",
+        {},
+        ModernGetTaskResultSchema,
+      ),
+    ).rejects.toMatchObject({
+      code: SdkErrorCode.RequestTimeout,
+      message: expect.stringMatching(
+        /^Request timed out after 1m00s \(tasks\/get\)\. /,
+      ),
+      data: expect.objectContaining({ timeout: 60_000, method: "tasks/get" }),
+    });
+  });
+
+  it("leaves a non-timeout send failure untouched", async () => {
+    const client = makeClient();
+    const boom = new Error("connection closed");
+    internals(client).transport = { send: vi.fn().mockRejectedValue(boom) };
+    await expect(
+      internals(client).rawWireRequest(
+        "tasks/get",
+        {},
+        ModernGetTaskResultSchema,
+      ),
+    ).rejects.toBe(boom);
+  });
+
   it("rejects all pending requests on teardown", async () => {
     const client = makeClient();
     internals(client).transport = {
diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts
index a254db94..602d501b 100644
--- a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts
+++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts
@@ -150,8 +150,9 @@ describe("InspectorClient skills methods (#2234)", () => {
 
   it("requires the modern list envelope on a modern connection", async () => {
     // SEP-2640: "In protocol versions 2026-07-28 and later, the result also
-    // carries … `ttlMs` and `cacheScope`." Nothing else validates it —
-    // `skills/*` is consumer-owned, so the SDK codec never sees it.
+    // carries … `ttlMs` and `cacheScope`." The SDK codec checks and removes
+    // `resultType` on every modern result, but for a consumer-owned method it
+    // checks neither caching attribute — so this schema is what does.
     const client = makeClient();
     internals(client).protocolEra = "modern";
     stubRequest(client, { skills: [] });
@@ -159,10 +160,12 @@ describe("InspectorClient skills methods (#2234)", () => {
   });
 
   it("accepts a modern result that carries the envelope", async () => {
+    // No `resultType` in the stub: the SDK codec lifts it off before the
+    // client's schema runs, so this is the shape a real modern page arrives
+    // in (#2373).
     const client = makeClient();
     internals(client).protocolEra = "modern";
     stubRequest(client, {
-      resultType: "complete",
       ttlMs: 0,
       cacheScope: "public",
       skills: [ENTRY],
@@ -317,14 +320,18 @@ describe("InspectorClient skills methods (#2234)", () => {
       });
     });
 
-    it("requires resultType on a modern connection", async () => {
+    it("accepts a modern result as the SDK codec delivers it, without resultType (#2373)", async () => {
+      // The codec checks `resultType` and lifts it off before this schema
+      // runs, so a stub returning the lifted shape is what a real modern
+      // connection hands the client. Requiring it here failed every conforming
+      // server; the live check is in the integration suite.
       const client = makeClient();
       internals(client).protocolEra = "modern";
-      stubRequest(client, { resources: [] });
+      stubRequest(client, { resources: [CHILD] });
       declareSkills(client, true);
       await expect(
         client.readResourceDirectory("skill://demo"),
-      ).rejects.toBeDefined();
+      ).resolves.toMatchObject({ resources: [CHILD] });
     });
 
     it("accepts a legacy result without resultType", async () => {
@@ -388,21 +395,14 @@ describe("InspectorClient skills methods (#2234)", () => {
     });
   });
 
-  it("requires resultType on a modern skills/get", async () => {
-    // Base-protocol (SEP-2322) and present in SEP-2640's own example, unlike
-    // the caching attributes the SEP leaves open.
+  it("accepts a modern skills/get as the SDK codec delivers it, without resultType (#2373)", async () => {
+    // `resultType` is base-protocol (SEP-2322), so the codec enforces it and
+    // lifts it off before this schema runs — requiring it here rejected every
+    // conforming modern server. The caching attributes SEP-2640 leaves open
+    // stay optional too.
     const client = makeClient();
     internals(client).protocolEra = "modern";
     stubRequest(client, { skill: ENTRY });
-    await expect(
-      client.getSkill("skill://demo/SKILL.md"),
-    ).rejects.toBeDefined();
-  });
-
-  it("accepts a modern skills/get without the caching attributes", async () => {
-    const client = makeClient();
-    internals(client).protocolEra = "modern";
-    stubRequest(client, { skill: ENTRY, resultType: "complete" });
     await expect(client.getSkill("skill://demo/SKILL.md")).resolves.toEqual(
       ENTRY,
     );
diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts
index 5f7204a2..ea69da47 100644
--- a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts
+++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts
@@ -33,6 +33,62 @@ function makeTracked() {
 }
 
 describe("MessageTrackingTransport.send", () => {
+  it("reports a request the base transport failed to send, after tracking it (#2318)", async () => {
+    const { callbacks, base, tracked } = makeTracked();
+    const trackSendFailure = vi.fn();
+    const failing = new MessageTrackingTransport(base, {
+      ...callbacks,
+      trackSendFailure,
+    });
+    const boom = new Error("connection closed");
+    base.send = async () => {
+      throw boom;
+    };
+    const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const;
+    await expect(failing.send(request)).rejects.toBe(boom);
+    expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client");
+    expect(trackSendFailure).toHaveBeenCalledWith(request, boom);
+    // A signal already aborted before the send began: the frame never went
+    // out, so this is a failed send and rolls back.
+    trackSendFailure.mockClear();
+    const preAborted = new AbortController();
+    preAborted.abort();
+    const preAbortedRequest = {
+      jsonrpc: "2.0",
+      id: 2,
+      method: "tools/call",
+    } as const;
+    await expect(
+      failing.send(preAbortedRequest, { requestSignal: preAborted.signal }),
+    ).rejects.toBe(boom);
+    expect(trackSendFailure).toHaveBeenCalledWith(preAbortedRequest, boom);
+    // A signal aborted while the send is in flight is a cancellation: the
+    // request may well have reached the server, and stays unanswered.
+    trackSendFailure.mockClear();
+    const midSend = new AbortController();
+    base.send = async () => {
+      midSend.abort();
+      throw boom;
+    };
+    await expect(
+      failing.send(
+        { jsonrpc: "2.0", id: 3, method: "tools/call" },
+        { requestSignal: midSend.signal },
+      ),
+    ).rejects.toBe(boom);
+    expect(trackSendFailure).not.toHaveBeenCalled();
+    base.send = async () => {
+      throw boom;
+    };
+    // Only requests roll back: a failed notification has no pending entry.
+    trackSendFailure.mockClear();
+    await expect(
+      failing.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
+    ).rejects.toBe(boom);
+    expect(trackSendFailure).not.toHaveBeenCalled();
+    void tracked;
+  });
+
   it("tracks an outgoing request", async () => {
     const { callbacks, tracked } = makeTracked();
     const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const;
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 72983440..7e1e1689 100644
--- a/clients/web/src/test/core/mcp/node/config.test.ts
+++ b/clients/web/src/test/core/mcp/node/config.test.ts
@@ -12,6 +12,7 @@ import { join } from "node:path";
 import {
   parseKeyValuePair,
   parseHeaderPair,
+  parseProtocolEra,
   withDefaultCatalogPath,
   resolveServerConfigs,
   resolveServerSource,
@@ -44,6 +45,18 @@ describe("parseKeyValuePair", () => {
   });
 });
 
+describe("parseProtocolEra", () => {
+  it.each(["legacy", "auto", "modern"])("accepts %s", (era) => {
+    expect(parseProtocolEra(era)).toBe(era);
+  });
+
+  it("rejects an unknown era, naming the valid ones", () => {
+    expect(() => parseProtocolEra("Modern")).toThrow(
+      "Invalid protocol era: Modern. Valid eras are: legacy, auto, modern",
+    );
+  });
+});
+
 describe("parseHeaderPair", () => {
   it("splits on the first colon and trims both sides", () => {
     expect(parseHeaderPair("X-Test:   value")).toEqual({
diff --git a/clients/web/src/test/core/mcp/node/servers.test.ts b/clients/web/src/test/core/mcp/node/servers.test.ts
index a54aaa8d..3cd372d8 100644
--- a/clients/web/src/test/core/mcp/node/servers.test.ts
+++ b/clients/web/src/test/core/mcp/node/servers.test.ts
@@ -18,6 +18,7 @@ import {
   selectServerEntry,
   type ResolvedServer,
 } from "@inspector/core/mcp/node/servers.js";
+import { DEFAULT_CONNECTION_TIMEOUT_MS } from "@inspector/core/mcp/types.js";
 
 describe("headersToServerSettings", () => {
   it("returns undefined when no headers are given", () => {
@@ -32,6 +33,10 @@ describe("headersToServerSettings", () => {
     ]);
     expect(settings?.metadata).toEqual({});
     expect(settings?.roots).toEqual([]);
+    // The header-only shell is what the CLI/TUI hand the client when a file
+    // sets no timeout, so it must carry the product default — a regression
+    // back to 0 here would silently unbound every such connect (#2320).
+    expect(settings?.connectionTimeout).toBe(DEFAULT_CONNECTION_TIMEOUT_MS);
   });
 });
 
@@ -229,6 +234,74 @@ describe("loadServerEntries", () => {
     ).rejects.toThrow(/--catalog cannot be combined/);
   });
 
+  it("overrides the disk protocolEra with --protocol-era, preserving the rest", async () => {
+    const configPath = join(tempDir, "mcp.json");
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        mcpServers: {
+          web: {
+            type: "streamable-http",
+            url: "http://x/mcp",
+            headers: { Authorization: "Bearer disk" },
+            protocolEra: "auto",
+            requestTimeout: 9000,
+          },
+        },
+      }),
+    );
+
+    const servers = await loadServerEntries({
+      configPath,
+      protocolEra: "modern",
+    });
+    expect(servers.web?.settings?.protocolEra).toBe("modern");
+    expect(servers.web?.settings?.requestTimeout).toBe(9000);
+    expect(servers.web?.settings?.headers).toEqual([
+      { key: "Authorization", value: "Bearer disk" },
+    ]);
+  });
+
+  it("applies --protocol-era to a server that has no disk settings", async () => {
+    const configPath = join(tempDir, "mcp.json");
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        mcpServers: {
+          web: { type: "streamable-http", url: "http://x/mcp" },
+        },
+      }),
+    );
+
+    const servers = await loadServerEntries({
+      configPath,
+      protocolEra: "auto",
+    });
+    expect(servers.web?.settings).toMatchObject({
+      protocolEra: "auto",
+      headers: [],
+      connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
+    });
+  });
+
+  it("applies --protocol-era and --header together to an ad-hoc URL", async () => {
+    const servers = await loadServerEntries({
+      serverUrl: "http://x/mcp",
+      transport: "http",
+      headers: { Authorization: "Bearer t" },
+      protocolEra: "modern",
+    });
+    expect(servers.default?.settings).toMatchObject({
+      protocolEra: "modern",
+      headers: [{ key: "Authorization", value: "Bearer t" }],
+    });
+  });
+
+  it("gives an ad-hoc target with neither flag no settings (legacy default)", async () => {
+    const servers = await loadServerEntries({ target: ["my-server"] });
+    expect(servers.default?.settings).toBeUndefined();
+  });
+
   it("builds a single ad-hoc server from a positional target", async () => {
     const servers = await loadServerEntries({
       target: ["my-server", "--flag"],
diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts
index 7ae9edbf..27a42d61 100644
--- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts
+++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts
@@ -1,5 +1,10 @@
 import { describe, it, expect, vi } from "vitest";
 import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js";
+import {
+  OAUTH_TIMEOUT_WIRE_CODE,
+  OAuthRequestTimeoutError,
+  withOAuthRequestTimeout,
+} from "@inspector/core/auth/requestTimeout.js";
 
 function ok(body: object): Response {
   return new Response(JSON.stringify(body), {
@@ -168,4 +173,225 @@ describe("createRemoteFetch", () => {
     expect(res.headers.get("x-test")).toBe("yes");
     expect(await res.text()).toBe("hello");
   });
+
+  describe("cancellation forwarding (#2319)", () => {
+    // Without this the caller's abort settled only its own promise: the POST
+    // stayed in flight, the backend never saw its request cancelled, and its
+    // outbound fetch to the authorization server ran on detached.
+    function capture() {
+      const fetchFn = vi
+        .fn()
+        .mockResolvedValue(ok(standardRemoteBody));
+      const remoteFetch = createRemoteFetch({
+        baseUrl: "http://remote.example",
+        // A contextually typed forwarding function rather than a double cast:
+        // the mock stays inspectable and nothing bypasses the type system.
+        fetchFn: (input, init) => fetchFn(input, init),
+      });
+      return { fetchFn, remoteFetch };
+    }
+
+    function signalOf(fetchFn: ReturnType>) {
+      return (fetchFn.mock.calls[0][1] as RequestInit).signal;
+    }
+
+    it("forwards init.signal onto the proxy hop", async () => {
+      const { fetchFn, remoteFetch } = capture();
+      const caller = new AbortController();
+
+      await remoteFetch("http://upstream.example/", { signal: caller.signal });
+
+      expect(signalOf(fetchFn)).toBe(caller.signal);
+    });
+
+    it("forwards a Request's own signal when init has none", async () => {
+      const { fetchFn, remoteFetch } = capture();
+      const caller = new AbortController();
+      // Compared against `request.signal`, not `caller.signal`: the Fetch
+      // standard gives `new Request(url, { signal })` a *dependent* signal, so
+      // the two are distinct objects in a spec-compliant runtime. Asserting
+      // against the controller's would encode non-standard identity and could
+      // pass or fail on the host rather than on the behaviour (Copilot).
+      const request = new Request("http://upstream.example/", {
+        signal: caller.signal,
+      });
+
+      await remoteFetch(request);
+
+      expect(signalOf(fetchFn)).toBe(request.signal);
+    });
+
+    it("treats an undefined init.signal as absent, keeping the Request's", async () => {
+      // WebIDL dictionary conversion: a member present as `undefined` is absent.
+      const { fetchFn, remoteFetch } = capture();
+      const caller = new AbortController();
+      const request = new Request("http://upstream.example/", {
+        signal: caller.signal,
+      });
+
+      await remoteFetch(request, { signal: undefined });
+
+      // Against `request.signal` for the dependent-signal reason above.
+      expect(signalOf(fetchFn)).toBe(request.signal);
+    });
+
+    it("honours an explicit null init.signal as no signal", async () => {
+      const { fetchFn, remoteFetch } = capture();
+      const caller = new AbortController();
+
+      await remoteFetch(
+        new Request("http://upstream.example/", { signal: caller.signal }),
+        { signal: null },
+      );
+
+      expect(signalOf(fetchFn)).toBeUndefined();
+    });
+
+    it("sends no signal when the caller supplied none", async () => {
+      const { fetchFn, remoteFetch } = capture();
+
+      await remoteFetch("http://upstream.example/");
+
+      expect(signalOf(fetchFn)).toBeUndefined();
+    });
+  });
+
+  describe("proxy-side deadline (#2319)", () => {
+    function remoteReturning(status: number, body: string) {
+      const fetchFn = vi.fn().mockResolvedValue(
+        new Response(body, {
+          status,
+          headers: { "content-type": "application/json" },
+        }),
+      );
+      return createRemoteFetch({
+        baseUrl: "http://remote.example",
+        // Forwarding function, not a double cast — see `capture` above.
+        fetchFn: (input, init) => fetchFn(input, init),
+      });
+    }
+
+    it("rebuilds an OAuthRequestTimeoutError from the route's marker", async () => {
+      // The proxy boundary in isolation: whichever end's deadline fires, a
+      // timeout that comes back as an ordinary error response must still
+      // reconstruct as a typed one. Both ends run the same budget in
+      // production, so this path is the backend winning the race — a
+      // backgrounded tab throttling `setTimeout` is the plausible case — and
+      // which one fired must not decide whether the error names its endpoint.
+      const remoteFetch = remoteReturning(
+        504,
+        JSON.stringify({
+          error: "proxied request to https://as.example.com/x timed out",
+          code: OAUTH_TIMEOUT_WIRE_CODE,
+          url: "https://as.example.com/x",
+          timeoutMs: 30000,
+        }),
+      );
+
+      const err = await remoteFetch("https://as.example.com/x").catch(
+        (e: unknown) => e,
+      );
+
+      expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
+      const timeout = err as OAuthRequestTimeoutError;
+      expect(timeout.url).toBe("https://as.example.com/x");
+      expect(timeout.timeoutMs).toBe(30000);
+    });
+
+    it("leaves an ordinary error response as a plain Error", async () => {
+      const remoteFetch = remoteReturning(
+        500,
+        JSON.stringify({ error: "connect ECONNREFUSED" }),
+      );
+
+      const err = await remoteFetch("https://as.example.com/x").catch(
+        (e: unknown) => e,
+      );
+
+      expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
+      expect((err as Error).message).toMatch(/Remote fetch failed \(500\)/);
+    });
+
+    it("ignores a non-JSON error body rather than throwing on the parse", async () => {
+      const remoteFetch = remoteReturning(502, "bad gateway");
+
+      const err = await remoteFetch("https://as.example.com/x").catch(
+        (e: unknown) => e,
+      );
+
+      expect((err as Error).message).toMatch(/Remote fetch failed \(502\)/);
+    });
+
+    it("ignores a marker whose fields are the wrong shape", async () => {
+      // An upstream could serve JSON of its own through a failing proxy; the
+      // guard checks the field types, not just the code.
+      const remoteFetch = remoteReturning(
+        504,
+        JSON.stringify({ code: OAUTH_TIMEOUT_WIRE_CODE, url: 5 }),
+      );
+
+      const err = await remoteFetch("https://as.example.com/x").catch(
+        (e: unknown) => e,
+      );
+
+      expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
+    });
+  });
+
+  describe("carrying the caller's deadline to the route (#2319)", () => {
+    function envelopeOf(fetchFn: ReturnType>) {
+      const init = fetchFn.mock.calls[0][1] as RequestInit;
+      return JSON.parse(init.body as string) as Record;
+    }
+
+    it("puts a bounded call's budget in the envelope, not in the headers", async () => {
+      const fetchFn = vi
+        .fn()
+        .mockResolvedValue(ok(standardRemoteBody));
+      const remoteFetch = createRemoteFetch({
+        baseUrl: "http://remote.example",
+        fetchFn: (input, init) => fetchFn(input, init),
+      });
+      // Composed the way `InspectorClient` composes it: the wrapper outside,
+      // the remote fetch beneath.
+      const bounded = withOAuthRequestTimeout(remoteFetch, 7000);
+
+      await bounded("http://upstream.example/token");
+
+      const envelope = envelopeOf(fetchFn);
+      expect(envelope.timeoutMs).toBe(7000);
+      // Not a header: `headers` is re-sent verbatim to the upstream server, so
+      // a marker there would leak the Inspector's internals to a third party.
+      expect(JSON.stringify(envelope.headers)).not.toContain("7000");
+    });
+
+    it("omits the budget for an exempt call, so the route bounds nothing", async () => {
+      const fetchFn = vi
+        .fn()
+        .mockResolvedValue(ok(standardRemoteBody));
+      const remoteFetch = createRemoteFetch({
+        baseUrl: "http://remote.example",
+        fetchFn: (input, init) => fetchFn(input, init),
+      });
+      const bounded = withOAuthRequestTimeout(remoteFetch, 7000, () => true);
+
+      await bounded("http://upstream.example/mcp");
+
+      expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
+    });
+
+    it("omits the budget for a direct call that no wrapper bounded", async () => {
+      const fetchFn = vi
+        .fn()
+        .mockResolvedValue(ok(standardRemoteBody));
+      const remoteFetch = createRemoteFetch({
+        baseUrl: "http://remote.example",
+        fetchFn: (input, init) => fetchFn(input, init),
+      });
+
+      await remoteFetch("http://upstream.example/mcp");
+
+      expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
+    });
+  });
 });
diff --git a/clients/web/src/test/core/mcp/remote/progressToken.test.ts b/clients/web/src/test/core/mcp/remote/progressToken.test.ts
new file mode 100644
index 00000000..dc00cfee
--- /dev/null
+++ b/clients/web/src/test/core/mcp/remote/progressToken.test.ts
@@ -0,0 +1,115 @@
+/**
+ * Unit tests for the shared `progressTokenOf` guard (#2028) — the string/safe
+ * integer extractor both relay waits (browser `RemoteClientTransport` and Node
+ * `RemoteSession`) use to correlate a `notifications/progress` with the request
+ * whose timeout it should re-arm. Covers the protocol's accept set and, since
+ * the guard delegates to the SDK's `ProgressTokenSchema`, the fractional and
+ * past-`MAX_SAFE_INTEGER` values a bare `typeof` check would have let through.
+ */
+import { describe, it, expect } from "vitest";
+import { progressTokenOf } from "@inspector/core/mcp/remote/progressToken.js";
+import type { JSONRPCMessage } from "@modelcontextprotocol/client";
+
+// Cast helper: these fixtures are deliberately off-spec to exercise the guard
+// branches, which the typed JSONRPCMessage shape would otherwise forbid.
+const msg = (m: unknown): JSONRPCMessage => m as JSONRPCMessage;
+
+describe("progressTokenOf (#2028)", () => {
+  it("returns a numeric progressToken from a progress notification", () => {
+    expect(
+      progressTokenOf({
+        jsonrpc: "2.0",
+        method: "notifications/progress",
+        params: { progressToken: 42, progress: 1, total: 3 },
+      }),
+    ).toBe(42);
+  });
+
+  it("returns a string progressToken", () => {
+    expect(
+      progressTokenOf({
+        jsonrpc: "2.0",
+        method: "notifications/progress",
+        params: { progressToken: "abc", progress: 1 },
+      }),
+    ).toBe("abc");
+  });
+
+  it("returns undefined for a different notification method", () => {
+    expect(
+      progressTokenOf({
+        jsonrpc: "2.0",
+        method: "notifications/message",
+        params: { level: "info", data: {} },
+      }),
+    ).toBeUndefined();
+  });
+
+  it("returns undefined for a JSON-RPC response (no method)", () => {
+    expect(
+      progressTokenOf({ jsonrpc: "2.0", id: 1, result: {} }),
+    ).toBeUndefined();
+  });
+
+  it("returns undefined when a progress note carries no params", () => {
+    expect(
+      progressTokenOf(
+        msg({ jsonrpc: "2.0", method: "notifications/progress" }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it("returns undefined when params is null", () => {
+    expect(
+      progressTokenOf(
+        msg({ jsonrpc: "2.0", method: "notifications/progress", params: null }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it("returns undefined when the progressToken is neither string nor number", () => {
+    expect(
+      progressTokenOf(
+        msg({
+          jsonrpc: "2.0",
+          method: "notifications/progress",
+          params: { progressToken: { nested: true } },
+        }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it("rejects a fractional numeric token (the protocol allows only integers)", () => {
+    expect(
+      progressTokenOf(
+        msg({
+          jsonrpc: "2.0",
+          method: "notifications/progress",
+          params: { progressToken: 1.5, progress: 1 },
+        }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it("rejects an integer past MAX_SAFE_INTEGER", () => {
+    expect(
+      progressTokenOf(
+        msg({
+          jsonrpc: "2.0",
+          method: "notifications/progress",
+          params: { progressToken: Number.MAX_SAFE_INTEGER + 1, progress: 1 },
+        }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it("accepts a safe integer at the boundary", () => {
+    expect(
+      progressTokenOf({
+        jsonrpc: "2.0",
+        method: "notifications/progress",
+        params: { progressToken: Number.MAX_SAFE_INTEGER, progress: 1 },
+      }),
+    ).toBe(Number.MAX_SAFE_INTEGER);
+  });
+});
diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts
index 1aed2bea..d25eba92 100644
--- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts
+++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
 import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
 
@@ -686,4 +686,125 @@ describe("RemoteClientTransport", () => {
       await transport.close();
     });
   });
+
+  describe("progress notifications keep the SSE response wait alive (#2028)", () => {
+    // A remote backend holds POST /api/mcp/send open until the JSON-RPC
+    // response, and the browser bounds that wait with `sseResponseTimeoutMs`.
+    // For a long tool that reports progress, that wait must re-arm on each
+    // `notifications/progress` — mirroring the SDK client's
+    // `resetTimeoutOnProgress` — or the web client alone times out a call that
+    // every other transport rides out. Log messages must NOT re-arm it.
+    function backendHoldingSend(): {
+      fetchFn: typeof fetch;
+      getSse: () => ReturnType;
+      getSentId: () => string | number | undefined;
+    } {
+      let sse!: ReturnType;
+      let sentId: string | number | undefined;
+      const fetchFn = vi
+        .fn()
+        .mockImplementation(async (input, init) => {
+          const url = String(input);
+          if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
+            return new Response(JSON.stringify({ sessionId: "s-progress" }), {
+              status: 200,
+            });
+          }
+          if (url.includes("/api/mcp/events")) {
+            sse = createPushableSseStream();
+            return sse.response;
+          }
+          if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
+            const body = JSON.parse(String(init.body)) as {
+              message: { id?: string | number };
+            };
+            sentId = body.message.id;
+            // Backend accepted the request; the response arrives later over SSE.
+            return new Response(JSON.stringify({ ok: true }), { status: 200 });
+          }
+          return new Response("not found", { status: 404 });
+        });
+      return { fetchFn, getSse: () => sse, getSentId: () => sentId };
+    }
+
+    // Fake timers so the deadline is driven deterministically rather than
+    // raced against a real `setTimeout` — a real-sleep test can let the timeout
+    // fire before a loaded runner processes the enqueued SSE frame (#1596: a
+    // race is fixed with fake timers, never with headroom). Each push is
+    // followed by a 0ms async advance to flush the SSE consumer's stream read
+    // so the frame is parsed before time moves.
+    const TIMEOUT_MS = 1000;
+    const flushSse = () => vi.advanceTimersByTimeAsync(0);
+
+    beforeEach(() => vi.useFakeTimers());
+    afterEach(() => vi.useRealTimers());
+
+    it("re-arms the wait on each progress note, so a call outliving the timeout still resolves", async () => {
+      const { fetchFn, getSse, getSentId } = backendHoldingSend();
+      const transport = new RemoteClientTransport(
+        { baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
+        config,
+      );
+      await transport.start();
+
+      const sent = transport.send({
+        jsonrpc: "2.0",
+        id: 1,
+        method: "tools/call",
+      });
+      await flushSse();
+
+      // Three progress notes, each after 900ms (< the 1000ms window): without
+      // the re-arm the wait dies at 1000ms, well before the third lands.
+      for (let i = 0; i < 3; i++) {
+        getSse().pushMessage({
+          jsonrpc: "2.0",
+          method: "notifications/progress",
+          params: { progressToken: getSentId(), progress: i, total: 3 },
+        });
+        await flushSse();
+        await vi.advanceTimersByTimeAsync(900);
+      }
+      getSse().pushMessage({
+        jsonrpc: "2.0",
+        id: getSentId(),
+        result: { ok: true },
+      });
+      await flushSse();
+
+      await expect(sent).resolves.toBeUndefined();
+      await transport.close();
+    });
+
+    it("does not re-arm on notifications/message, so a log-only call still times out", async () => {
+      const { fetchFn, getSse, getSentId } = backendHoldingSend();
+      const transport = new RemoteClientTransport(
+        { baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
+        config,
+      );
+      await transport.start();
+
+      const sent = transport.send({
+        jsonrpc: "2.0",
+        id: 1,
+        method: "tools/call",
+      });
+      // Hold the expected rejection now (as the RemoteSession timeout tests do)
+      // so it is asserted rather than suppressed by an empty catch.
+      const rejection = expect(sent).rejects.toThrow(/Request timed out/);
+      await flushSse();
+
+      // A log message before the deadline must not extend it.
+      getSse().pushMessage({
+        jsonrpc: "2.0",
+        method: "notifications/message",
+        params: { level: "info", data: { msg: "tick", token: getSentId() } },
+      });
+      await flushSse();
+      await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
+
+      await rejection;
+      await transport.close();
+    });
+  });
 });
diff --git a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts
index a8fa25bb..a0b5249f 100644
--- a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts
+++ b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts
@@ -110,6 +110,45 @@ describe("RemoteInspectorClientStorage", () => {
     );
   });
 
+  it("loadSession revives a stream's closedAt alongside the entry timestamp (#2318)", async () => {
+    const fetchFn = vi.fn(
+      async () =>
+        new Response(
+          JSON.stringify({
+            fetchRequests: [
+              {
+                id: "open",
+                timestamp: "2026-01-01T00:00:00.000Z",
+                method: "GET",
+                url: "http://example.com/mcp",
+                requestHeaders: {},
+                stream: { eventCount: 2 },
+              },
+              {
+                id: "closed",
+                timestamp: "2026-01-01T00:00:00.000Z",
+                method: "GET",
+                url: "http://example.com/mcp",
+                requestHeaders: {},
+                stream: { eventCount: 3, closedAt: "2026-01-01T00:00:05.000Z" },
+              },
+            ],
+            createdAt: 1735689600000,
+            updatedAt: 1735689600001,
+          }),
+          { status: 200 },
+        ),
+    );
+    const storage = makeStorage(fetchFn);
+    const state = await storage.loadSession("sid");
+    // An open stream's state passes through untouched.
+    expect(state?.fetchRequests[0]?.stream).toEqual({ eventCount: 2 });
+    const closed = state?.fetchRequests[1]?.stream;
+    expect(closed?.eventCount).toBe(3);
+    expect(closed?.closedAt).toBeInstanceOf(Date);
+    expect(closed?.closedAt?.toISOString()).toBe("2026-01-01T00:00:05.000Z");
+  });
+
   it("loadSession preserves Date timestamps already provided as Date instances", async () => {
     const fetchFn = vi.fn(
       async () =>
diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts
index 85553e18..eb3ebc22 100644
--- a/clients/web/src/test/core/mcp/serverList.test.ts
+++ b/clients/web/src/test/core/mcp/serverList.test.ts
@@ -21,6 +21,7 @@ import {
   serializeMcpConfig,
   storedFieldsToInspectorSettings,
 } from "@inspector/core/mcp/serverList.js";
+import { DEFAULT_CONNECTION_TIMEOUT_MS } from "@inspector/core/mcp/types.js";
 import type {
   InspectorServerSettings,
   MCPServerConfig,
@@ -258,7 +259,10 @@ describe("serverEntriesToMcpConfig", () => {
           url: "https://x.test/mcp",
           headers: { Authorization: "Bearer xyz" },
           metadata: { tenant: "acme", limits: { rps: 10 } },
-          connectionTimeout: 30000,
+          // Non-default on purpose: 30000 is DEFAULT_CONNECTION_TIMEOUT_MS,
+          // which the write side omits (as it does taskTtl's 60000), so it
+          // would not survive a byte-equal round-trip (#2320).
+          connectionTimeout: 45000,
           requestTimeout: 60000,
           oauth: {
             clientId: "client-abc",
@@ -452,6 +456,55 @@ describe("serverEntriesToMcpConfig", () => {
     expect(round).toEqual(original);
   });
 
+  it("round-trips the skills catalog budget (#2294)", () => {
+    const original: MCPConfig = {
+      mcpServers: {
+        sk: {
+          type: "streamable-http",
+          url: "https://x.test/mcp",
+          skillCatalogMaxSkills: 10,
+          skillCatalogMaxBytes: 2048,
+        },
+      },
+    };
+    const [entry] = mcpConfigToServerEntries(original);
+    expect(entry?.settings?.skillCatalogMaxSkills).toBe(10);
+    expect(entry?.settings?.skillCatalogMaxBytes).toBe(2048);
+    const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
+    expect(round).toEqual(original);
+  });
+
+  it("drops an unusable skills catalog limit on read and omits the default on write", () => {
+    const [entry] = mcpConfigToServerEntries({
+      mcpServers: {
+        sk: {
+          type: "streamable-http",
+          url: "https://x.test/mcp",
+          skillCatalogMaxSkills: 0,
+          skillCatalogMaxBytes: 1.5,
+        },
+      },
+    });
+    // Present on disk still materializes a settings node, but neither value
+    // is usable, so both read back as absent (the default budget).
+    expect(entry?.settings).toBeDefined();
+    expect(entry?.settings?.skillCatalogMaxSkills).toBeUndefined();
+    expect(entry?.settings?.skillCatalogMaxBytes).toBeUndefined();
+
+    const round = serverEntriesToMcpConfig([
+      {
+        ...entry!,
+        settings: {
+          ...entry!.settings!,
+          skillCatalogMaxSkills: 256,
+          skillCatalogMaxBytes: 64 * 1024 * 1024,
+        },
+      },
+    ]);
+    expect("skillCatalogMaxSkills" in (round.mcpServers.sk ?? {})).toBe(false);
+    expect("skillCatalogMaxBytes" in (round.mcpServers.sk ?? {})).toBe(false);
+  });
+
   it("round-trips protocolEra: lifts a non-default value to settings and back to disk", () => {
     const original: MCPConfig = {
       mcpServers: {
@@ -590,7 +643,8 @@ describe("serverEntriesToMcpConfig", () => {
       // Non-stdio server → empty env mirror in memory (for the form)
       env: [],
       metadata: {},
-      connectionTimeout: 0,
+      // Absent connectionTimeout on disk → product default in memory (#2320)
+      connectionTimeout: 30000,
       requestTimeout: 0,
       // Absent taskTtl on disk → product default in memory (for the form)
       taskTtl: 60000,
@@ -685,11 +739,45 @@ describe("serverEntriesToMcpConfig", () => {
     expect(stored?.headers).toEqual({ "X-Tenant": "acme" });
   });
 
-  it("omits zero-valued timeouts and empty oauth fields on serialize", () => {
-    // The form keeps numeric defaults at 0 and empty-string OAuth values.
-    // Round-tripping them onto disk would leave noisy `connectionTimeout: 0`
-    // / `oauth: {}` keys; suppress them so the diff stays minimal for
-    // entries the user never customized.
+  it("omits default-valued timeouts and empty oauth fields on serialize", () => {
+    // The form keeps the timeouts at their defaults (the 30 s product default
+    // for connectionTimeout, 0 = "SDK default" for requestTimeout) and
+    // empty-string OAuth values. Round-tripping them onto disk would leave
+    // noisy `connectionTimeout: 30000` / `oauth: {}` keys; suppress them so
+    // the diff stays minimal for entries the user never customized.
+    const entries: ServerEntry[] = [
+      {
+        id: "alpha",
+        name: "alpha",
+        config: { type: "streamable-http", url: "https://x.test" },
+        settings: {
+          headers: [],
+          env: [],
+          metadata: {},
+          connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
+          requestTimeout: 0,
+          taskTtl: 0,
+          maxFetchRequests: 1000,
+          roots: [],
+        },
+        connection: { status: "disconnected" },
+      },
+    ];
+    const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
+    expect(stored).not.toHaveProperty("connectionTimeout");
+    expect(stored).not.toHaveProperty("requestTimeout");
+    expect(stored).not.toHaveProperty("taskTtl");
+    expect(stored).not.toHaveProperty("oauth");
+    expect(stored).not.toHaveProperty("headers");
+    expect(stored).not.toHaveProperty("metadata");
+    expect(stored).not.toHaveProperty("roots");
+  });
+
+  it("persists an explicit connectionTimeout of 0 so the opt-out round-trips (#2320)", () => {
+    // 0 used to be the default and was suppressed on disk. Now that an absent
+    // field reads back as the 30 s product default, a suppressed 0 would
+    // silently turn "no timeout" into 30 s on the next load — so 0 is a real
+    // value here, written and read back as itself.
     const entries: ServerEntry[] = [
       {
         id: "alpha",
@@ -709,13 +797,11 @@ describe("serverEntriesToMcpConfig", () => {
       },
     ];
     const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
-    expect(stored).not.toHaveProperty("connectionTimeout");
-    expect(stored).not.toHaveProperty("requestTimeout");
-    expect(stored).not.toHaveProperty("taskTtl");
-    expect(stored).not.toHaveProperty("oauth");
-    expect(stored).not.toHaveProperty("headers");
-    expect(stored).not.toHaveProperty("metadata");
-    expect(stored).not.toHaveProperty("roots");
+    expect(stored?.connectionTimeout).toBe(0);
+    const [entry] = mcpConfigToServerEntries({
+      mcpServers: { alpha: stored! },
+    });
+    expect(entry?.settings?.connectionTimeout).toBe(0);
   });
 
   it("round-trips roots (uri + optional name) onto the top-level disk field", () => {
diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts
index 04a250b5..552b02e0 100644
--- a/clients/web/src/test/core/mcp/skills.test.ts
+++ b/clients/web/src/test/core/mcp/skills.test.ts
@@ -174,6 +174,36 @@ describe("normalizeSkillUri", () => {
     // `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
     // could not be decided on it — accepting it would reopen the hole.
     expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined();
+    // Path-less but still opaque: no authority, so it stays rejected.
+    expect(normalizeSkillUri("skill:demo")).toBeUndefined();
+    // Empty path AND no authority — the parser reports `pathname === ""` for
+    // these too, so an empty path alone must not be read as the root form.
+    expect(normalizeSkillUri("skill:")).toBeUndefined();
+    expect(normalizeSkillUri("mailto:")).toBeUndefined();
+    expect(normalizeSkillUri("skill://")).toBeUndefined();
+  });
+
+  it("accepts a path-less authority URI — a skill's root directory (#2295)", () => {
+    // RFC 3986 gives an authority URI an empty path, so `skill://data-analysis`
+    // is well-formed; rejecting it mislabelled a root echoed back by a
+    // directory read as "outside this skill".
+    expect(normalizeSkillUri("skill://data-analysis")).toBe(
+      "skill://data-analysis",
+    );
+    expect(normalizeSkillUri("skill://%64ata-analysis")).toBe(
+      "skill://data-analysis",
+    );
+  });
+
+  it("still treats a path-less root as naming no skill file", () => {
+    // Accepting the root must not let it pass a check that needs a file.
+    expect(skillNameFromUri("skill://data-analysis")).toBeUndefined();
+    const issues = checkSkillConformance({
+      uri: "skill://data-analysis",
+      frontmatter: { name: "data-analysis", description: "d" },
+      resources: [],
+    });
+    expect(issues.map((issue) => issue.code)).toContain("malformed-uri");
   });
 
   it("decodes escapes that stand for unreserved characters", () => {
diff --git a/clients/web/src/test/core/mcp/skillsSchemas.test.ts b/clients/web/src/test/core/mcp/skillsSchemas.test.ts
index 53f991e6..bbe78184 100644
--- a/clients/web/src/test/core/mcp/skillsSchemas.test.ts
+++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts
@@ -2,12 +2,10 @@ import { describe, it, expect } from "vitest";
 import {
   DIRECTORY_MIME_TYPE,
   DirectoryReadResultSchema,
-  ModernDirectoryReadResultSchema,
   RESOURCES_DIRECTORY_READ_METHOD,
   DYNAMIC_RESOURCES,
   GetSkillEnvelopeSchema,
   GetSkillResultSchema,
-  ModernGetSkillEnvelopeSchema,
   ListSkillsResultSchema,
   ModernListSkillsResultSchema,
   SKILLS_EXTENSION_KEY,
@@ -95,9 +93,11 @@ describe("ListSkillsResultSchema", () => {
 });
 
 describe("ModernListSkillsResultSchema", () => {
-  const envelope = { resultType: "complete", ttlMs: 0, cacheScope: "public" };
+  // What the schema actually receives on a modern connection: the SDK codec
+  // has already checked `resultType` and lifted it off the result (#2373).
+  const envelope = { ttlMs: 0, cacheScope: "public" };
 
-  it("accepts a modern page carrying the base list envelope", () => {
+  it("accepts a modern page carrying the caching attributes", () => {
     const parsed = ModernListSkillsResultSchema.parse({
       ...envelope,
       skills: [ENTRY],
@@ -105,10 +105,20 @@ describe("ModernListSkillsResultSchema", () => {
     expect(parsed.skills).toHaveLength(1);
   });
 
+  it("does not require resultType, which the SDK codec lifts before this schema runs (#2373)", () => {
+    // Requiring it rejected every conforming modern page: the codec checks
+    // `resultType` itself, then deletes it. An integration test proves the
+    // lift against a real connection; this pins the schema's side of it.
+    expect(
+      ModernListSkillsResultSchema.safeParse({ ...envelope, skills: [] })
+        .success,
+    ).toBe(true);
+  });
+
   it("rejects a modern page that omits the caching attributes", () => {
     // The whole reason for the era split: `skills/*` is consumer-owned, so the
-    // SDK codec validates none of it, and `{ skills: [] }` would otherwise
-    // reach the conformance UI as a clean list.
+    // SDK codec checks none of the caching attributes, and `{ skills: [] }`
+    // would otherwise reach the conformance UI as a clean list.
     expect(() => ModernListSkillsResultSchema.parse({ skills: [] })).toThrow();
     expect(() =>
       ModernListSkillsResultSchema.parse({
@@ -195,9 +205,6 @@ describe("directory read schemas (#2248)", () => {
       ],
     };
     expect(DirectoryReadResultSchema.safeParse(example).success).toBe(true);
-    expect(ModernDirectoryReadResultSchema.safeParse(example).success).toBe(
-      true,
-    );
   });
 
   it("accepts an empty directory", () => {
@@ -230,39 +237,24 @@ describe("directory read schemas (#2248)", () => {
     ).toBe(false);
   });
 
-  it("requires resultType on the modern variant only", () => {
-    const legacyShape = { resources: [CHILD] };
-    expect(DirectoryReadResultSchema.safeParse(legacyShape).success).toBe(true);
-    expect(ModernDirectoryReadResultSchema.safeParse(legacyShape).success).toBe(
-      false,
-    );
-  });
-
-  it("does NOT require the caching attributes on the modern variant", () => {
+  it("does NOT require the caching attributes, unlike a modern skills/list page", () => {
     // The deliberate asymmetry with `ModernListSkillsResultSchema`: SEP-2640
     // states `ttlMs`/`cacheScope` for a modern `skills/list` and says nothing
     // of the kind for this method, whose only worked example omits them.
     // Requiring them would fail a server that matched the spec's own example.
-    expect(
-      ModernDirectoryReadResultSchema.safeParse({
-        resultType: "complete",
-        resources: [],
-      }).success,
-    ).toBe(true);
-    expect(
-      ModernListSkillsResultSchema.safeParse({
-        resultType: "complete",
-        skills: [],
-      }).success,
-    ).toBe(false);
+    expect(DirectoryReadResultSchema.safeParse({ resources: [] }).success).toBe(
+      true,
+    );
+    expect(ModernListSkillsResultSchema.safeParse({ skills: [] }).success).toBe(
+      false,
+    );
   });
 
   it("still accepts the caching attributes when a server sends them", () => {
     // Permitted, not mandated — a schema is not the place to reject an extra
     // member the spec leaves open.
     expect(
-      ModernDirectoryReadResultSchema.safeParse({
-        resultType: "complete",
+      DirectoryReadResultSchema.safeParse({
         resources: [],
         ttlMs: 60,
         cacheScope: "public",
@@ -286,25 +278,10 @@ describe("GetSkillResultSchema caching attributes (#2248)", () => {
     ).toBe(true);
   });
 
-  it("requires resultType on the modern envelope, but still not the caching fields", () => {
-    // "Left open" covers `ttlMs` / `cacheScope` and only those. `resultType` is
-    // base-protocol (SEP-2322) and appears in SEP-2640's own `skills/get`
-    // example, so leaving it optional here while requiring it of
-    // `resources/directory/read` was an inconsistency in this module rather
-    // than a distinction the spec draws (Copilot).
-    expect(
-      ModernGetSkillEnvelopeSchema.safeParse({ skill: ENTRY }).success,
-    ).toBe(false);
-    expect(
-      ModernGetSkillEnvelopeSchema.safeParse({
-        skill: ENTRY,
-        resultType: "complete",
-      }).success,
-    ).toBe(true);
-  });
-
-  it("keeps the legacy envelope permissive about resultType", () => {
-    // A 2026-era member a legacy server has no business sending.
+  it("serves both eras with one envelope schema (#2373)", () => {
+    // The modern variant used to add a required `resultType`, which the SDK
+    // codec lifts before any caller schema runs — so it could never pass on a
+    // real modern connection. With that gone the two eras want the same shape.
     expect(GetSkillEnvelopeSchema.safeParse({ skill: ENTRY }).success).toBe(
       true,
     );
diff --git a/clients/web/src/test/core/mcp/skillsVerification.test.ts b/clients/web/src/test/core/mcp/skillsVerification.test.ts
index e6ff5b07..6ca26d48 100644
--- a/clients/web/src/test/core/mcp/skillsVerification.test.ts
+++ b/clients/web/src/test/core/mcp/skillsVerification.test.ts
@@ -1,6 +1,9 @@
 import { describe, it, expect, vi } from "vitest";
 import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol.js";
-import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
+import {
+  DYNAMIC_RESOURCES,
+  type SkillEntry,
+} from "@inspector/core/mcp/skillsSchemas.js";
 import {
   SKILL_MAX_CATALOG_SKILLS,
   sha256Digest,
@@ -790,6 +793,181 @@ describe("verifySkills (#2248)", () => {
     expect(reports[0].outcome).toBe("verified");
   });
 
+  it("honors the server's configured catalog budget (#2294)", async () => {
+    // Read through `getServerSettings()`, so neither caller has to pass it.
+    const enc = new TextEncoder();
+    const mdFor = (i: number) =>
+      `---\nname: c${i}\ndescription: A demo\n---\n\n# c${i}\n`;
+    const skills = await Promise.all(
+      Array.from({ length: 4 }, async (_, i): Promise => {
+        const bytes = enc.encode(mdFor(i));
+        return entry({
+          uri: `skill://c${i}/SKILL.md`,
+          frontmatter: { name: `c${i}`, description: "A demo" },
+          resources: [
+            {
+              uri: `skill://c${i}/SKILL.md`,
+              digest: await sha256Digest(bytes),
+              size: bytes.byteLength,
+            },
+          ],
+        });
+      }),
+    );
+    const readResource = vi.fn(async (uri: string) => ({
+      result: {
+        contents: [
+          { uri, text: mdFor(Number(/c(\d+)/.exec(uri)?.[1] ?? "0")) },
+        ],
+      },
+    }));
+    const client = {
+      readResource,
+      getServerSettings: () => ({ skillCatalogMaxSkills: 2 }),
+    } as unknown as InspectorClientProtocol;
+    const reports = await verifySkills(client, skills);
+    expect(readResource.mock.calls.length).toBe(2);
+    expect(reports.map((r) => r.outcome)).toEqual([
+      "verified",
+      "verified",
+      "incomplete",
+      "incomplete",
+    ]);
+    // The reason names the limit that actually applied, not the default.
+    expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/);
+  });
+
+  it("honors a configured catalog BYTE budget (#2294)", async () => {
+    // Each skill's SKILL.md is ~50 bytes, so a 60-byte budget is spent by the
+    // second skill: the third is where reading stops.
+    const enc = new TextEncoder();
+    const mdFor = (i: number) =>
+      `---\nname: b${i}\ndescription: A demo\n---\n\n# b${i}\n`;
+    const skills = await Promise.all(
+      Array.from({ length: 4 }, async (_, i): Promise => {
+        const bytes = enc.encode(mdFor(i));
+        return entry({
+          uri: `skill://b${i}/SKILL.md`,
+          frontmatter: { name: `b${i}`, description: "A demo" },
+          resources: [
+            {
+              uri: `skill://b${i}/SKILL.md`,
+              digest: await sha256Digest(bytes),
+              size: bytes.byteLength,
+            },
+          ],
+        });
+      }),
+    );
+    const readResource = vi.fn(async (uri: string) => ({
+      result: {
+        contents: [
+          { uri, text: mdFor(Number(/b(\d+)/.exec(uri)?.[1] ?? "0")) },
+        ],
+      },
+    }));
+    const client = {
+      readResource,
+      getServerSettings: () => ({ skillCatalogMaxBytes: 60 }),
+    } as unknown as InspectorClientProtocol;
+    const reports = await verifySkills(client, skills);
+    expect(readResource.mock.calls.length).toBe(2);
+    expect(reports.map((r) => r.outcome)).toEqual([
+      "verified",
+      "verified",
+      "incomplete",
+      "incomplete",
+    ]);
+    expect(reports[2].incomplete).toMatch(/256 skills \/ 60 bytes/);
+  });
+
+  it("stops reading once the byte budget is exactly REACHED, not only exceeded", async () => {
+    // Every name is one digit, so every SKILL.md has the same length. With the
+    // budget set to exactly one file's charge, the first skill spends it and
+    // the second must not be read (Copilot, #2294).
+    const enc = new TextEncoder();
+    const mdFor = (i: number) =>
+      `---\nname: e${i}\ndescription: A demo\n---\n\n# e${i}\n`;
+    const oneFile = enc.encode(mdFor(0)).byteLength;
+    const skills = await Promise.all(
+      Array.from({ length: 3 }, async (_, i): Promise => {
+        const bytes = enc.encode(mdFor(i));
+        return entry({
+          uri: `skill://e${i}/SKILL.md`,
+          frontmatter: { name: `e${i}`, description: "A demo" },
+          resources: [
+            {
+              uri: `skill://e${i}/SKILL.md`,
+              digest: await sha256Digest(bytes),
+              size: bytes.byteLength,
+            },
+          ],
+        });
+      }),
+    );
+    const readResource = vi.fn(async (uri: string) => ({
+      result: {
+        contents: [
+          { uri, text: mdFor(Number(/e(\d+)/.exec(uri)?.[1] ?? "0")) },
+        ],
+      },
+    }));
+    const client = {
+      readResource,
+      getServerSettings: () => ({ skillCatalogMaxBytes: oneFile }),
+    } as unknown as InspectorClientProtocol;
+    const reports = await verifySkills(client, skills);
+    expect(readResource.mock.calls.length).toBe(1);
+    expect(reports.map((r) => r.outcome)).toEqual([
+      "verified",
+      "incomplete",
+      "incomplete",
+    ]);
+  });
+
+  it("charges a dynamic skill's SKILL.md read to the byte budget", async () => {
+    // A `"dynamic"` skill has no manifest, so its only read is the fallback
+    // SKILL.md fetch. Uncharged, `catalogBytes` stayed 0 and a byte budget of
+    // 1 read every dynamic skill in the catalog (Copilot).
+    const mdFor = (i: number) =>
+      `---\nname: d${i}\ndescription: A demo\n---\n\n# d${i}\n`;
+    const skills: SkillEntry[] = Array.from({ length: 3 }, (_, i) => ({
+      uri: `skill://d${i}/SKILL.md`,
+      frontmatter: { name: `d${i}`, description: "A demo" },
+      resources: DYNAMIC_RESOURCES,
+    }));
+    const readResource = vi.fn(async (uri: string) => ({
+      result: {
+        contents: [
+          { uri, text: mdFor(Number(/d(\d+)/.exec(uri)?.[1] ?? "0")) },
+        ],
+      },
+    }));
+    const client = {
+      readResource,
+      getServerSettings: () => ({ skillCatalogMaxBytes: 1 }),
+    } as unknown as InspectorClientProtocol;
+    const reports = await verifySkills(client, skills);
+    expect(readResource.mock.calls.length).toBe(1);
+    expect(reports[0].outcome).not.toBe("incomplete");
+    expect(reports[1].outcome).toBe("incomplete");
+    expect(reports[2].outcome).toBe("incomplete");
+  });
+
+  it("falls back to the default budget for an unusable configured limit", async () => {
+    const { skill, client } = await truncatable({ name: "fb", count: 2 });
+    const withSettings = {
+      ...client,
+      readResource: client.readResource,
+      getServerSettings: () => ({
+        skillCatalogMaxSkills: 0,
+        skillCatalogMaxBytes: -1,
+      }),
+    } as unknown as InspectorClientProtocol;
+    const [report] = await verifySkills(withSettings, [skill]);
+    expect(report.outcome).toBe("verified");
+  });
+
   it("is not incomplete when the budget is crossed by the LAST entry", async () => {
     // Crossing the line on the final row stopped nothing: every manifest entry
     // was fetched and checked. Reporting "Stopped after 4 of 4" there both
diff --git a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts
index d8b239b3..5e00da33 100644
--- a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts
+++ b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts
@@ -146,6 +146,51 @@ describe("FetchRequestLogState", () => {
     expect(seen).toHaveLength(1);
   });
 
+  it("patches the matching entry's stream state and re-emits on fetchRequestStreamUpdate (#2318)", () => {
+    client.dispatchTypedEvent("fetchRequest", entry("a"));
+    client.dispatchTypedEvent("fetchRequest", entry("b"));
+    const seen: FetchRequestEntry[][] = [];
+    state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail));
+
+    client.dispatchTypedEvent("fetchRequestStreamUpdate", {
+      id: "b",
+      stream: { eventCount: 2 },
+    });
+    const closedAt = new Date(2026, 4, 13, 0, 1);
+    client.dispatchTypedEvent("fetchRequestStreamUpdate", {
+      id: "b",
+      stream: { eventCount: 3, closedAt },
+    });
+
+    const entries = state.getFetchRequests();
+    expect(entries[0]?.stream).toBeUndefined();
+    // Each update replaces the previous state wholesale.
+    expect(entries[1]?.stream).toEqual({ eventCount: 3, closedAt });
+    expect(seen).toHaveLength(2);
+  });
+
+  it("ignores a stream update for an unknown id without emitting", () => {
+    client.dispatchTypedEvent("fetchRequest", entry("a"));
+    const seen: FetchRequestEntry[][] = [];
+    state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail));
+    client.dispatchTypedEvent("fetchRequestStreamUpdate", {
+      id: "gone",
+      stream: { eventCount: 1 },
+    });
+    expect(seen).toHaveLength(0);
+    expect(state.getFetchRequests()[0]?.stream).toBeUndefined();
+  });
+
+  it("stops patching stream state after destroy", () => {
+    client.dispatchTypedEvent("fetchRequest", entry("a"));
+    state.destroy();
+    client.dispatchTypedEvent("fetchRequestStreamUpdate", {
+      id: "a",
+      stream: { eventCount: 1 },
+    });
+    expect(state.getFetchRequests()).toEqual([]);
+  });
+
   it("silently ignores a body update for an unknown id when the log is not full (benign straggler)", () => {
     // Below capacity, an idx === -1 means the entry was cleared or never
     // existed — not a rotation drop — so it must NOT warn or emit the dropped
diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx
index f24e3692..107ba45e 100644
--- a/clients/web/src/test/core/react/useInspectorClient.test.tsx
+++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx
@@ -1,5 +1,6 @@
 import { describe, it, expect } from "vitest";
 import { act, renderHook } from "@testing-library/react";
+import { Client } from "@modelcontextprotocol/client";
 import type {
   ClientCapabilities,
   Implementation,
@@ -67,6 +68,42 @@ describe("useInspectorClient", () => {
     expect(result.current.instructions).toBe("after");
   });
 
+  it("returns the client's connection diagnostics and re-reads them on change (#2318)", () => {
+    const client = new FakeInspectorClient();
+    const { result } = renderHook(() => useInspectorClient(client));
+    expect(result.current.connectionDiagnostics).toEqual({
+      capturedAt: 0,
+      outstandingRequests: [],
+    });
+    const next = {
+      capturedAt: 10,
+      outstandingRequests: [{ id: 1, method: "tools/list", sentAt: 5 }],
+      lastResponse: { method: "initialize", receivedAt: 4 },
+    };
+    act(() => {
+      client.setConnectionDiagnostics(next);
+    });
+    expect(result.current.connectionDiagnostics).toBe(next);
+  });
+
+  it("falls back to an empty snapshot for a client without the optional accessor", () => {
+    const client = new FakeInspectorClient();
+    // `getConnectionDiagnostics` is optional on the protocol; a double that
+    // predates it must still yield the one shape consumers expect.
+    Object.defineProperty(client, "getConnectionDiagnostics", {
+      value: undefined,
+    });
+    const { result } = renderHook(() => useInspectorClient(client));
+    expect(result.current.connectionDiagnostics).toEqual({
+      capturedAt: 0,
+      outstandingRequests: [],
+    });
+    const { result: absent } = renderHook(() => useInspectorClient(null));
+    expect(absent.current.connectionDiagnostics).toBe(
+      result.current.connectionDiagnostics,
+    );
+  });
+
   it("subscribes to protocolVersionChange and updates", () => {
     const client = new FakeInspectorClient();
     const { result } = renderHook(() => useInspectorClient(client));
@@ -422,7 +459,9 @@ describe("useInspectorClient", () => {
     const { result, rerender } = renderHook(() => useInspectorClient(client));
     expect(result.current.appRendererClient).toBeNull();
 
-    const sentinel = { iam: "renderer" };
+    // A real (unconnected) SDK client: `AppRendererClient` is the SDK `Client`
+    // itself since ext-apps 2.0.0 (#1745), and this hook only relays it.
+    const sentinel = new Client({ name: "renderer-sentinel", version: "0" });
     client.setAppRendererClient(sentinel);
     rerender();
     expect(result.current.appRendererClient).toBe(sentinel);
diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx
index 248f60cf..764480f5 100644
--- a/clients/web/src/test/core/react/useServers.test.tsx
+++ b/clients/web/src/test/core/react/useServers.test.tsx
@@ -516,7 +516,9 @@ describe("useServers", () => {
       headers: [{ key: "X-Keep", value: "yes" }],
       env: [],
       metadata: {},
-      connectionTimeout: 0,
+      // Absent connectionTimeout on disk reads back as the 30 s product
+      // default for the form (#2320).
+      connectionTimeout: 30000,
       requestTimeout: 0,
       // Absent taskTtl on disk reads back as the product default for the form.
       taskTtl: 60000,
diff --git a/clients/web/src/test/integration/auth/node/file-lock.test.ts b/clients/web/src/test/integration/auth/node/file-lock.test.ts
index bd4d5929..fc043d0f 100644
--- a/clients/web/src/test/integration/auth/node/file-lock.test.ts
+++ b/clients/web/src/test/integration/auth/node/file-lock.test.ts
@@ -7,6 +7,14 @@
  * is one process-wide queue per path, so two in-process callers are ordered
  * before the lock ever sees them. A child process is not scaffolding here —
  * it is the only participant that can produce the interleaving.
+ *
+ * Four tests here raise their own budget past the integration project's 30s,
+ * and are among the handful in the repo that still should (#2323). What they
+ * wait on is not React or a renderer — it is `proper-lockfile`'s stale-lock
+ * timeout and a second OS process actually contending for the file, which is
+ * real elapsed work rather than slack. The named constants below say so at
+ * each site; the restatements of the project's own 30s that used to sit
+ * alongside them are gone.
  */
 import {
   describe,
@@ -28,6 +36,20 @@ import {
   withSecretFileLock,
   resetFileLockWarnings,
 } from "@inspector/core/auth/node/file-lock.js";
+
+/**
+ * A test that waits for `proper-lockfile`'s retries to run out against a holder
+ * that never yields — a live second process, or a stale lock it cannot clear.
+ * Nothing is taken over here; the budget is spent in full by construction,
+ * which is why it is the longer of the two.
+ */
+const LOCK_RETRIES_EXHAUSTED_MS = 90_000;
+/**
+ * A test that waits out the stale window and then *succeeds* by taking the dead
+ * holder's lock over. Shorter, because it ends the moment the takeover lands
+ * rather than when the retries stop.
+ */
+const STALE_LOCK_TAKEOVER_MS = 60_000;
 import { FileSecretStore } from "@inspector/core/auth/node/file-secret-store.js";
 import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
 
@@ -150,7 +172,7 @@ describe("withSecretFileLock across processes", () => {
     // …and having waited, it did not report a degraded write.
     expect(warnings()).toBe("");
     await done;
-  }, 20_000);
+  });
 
   it("locks a file that does not exist yet", async () => {
     // The first `set` on a fresh install has no `secrets.json` — and
@@ -204,7 +226,7 @@ describe("withSecretFileLock across processes", () => {
     expect(await reader.get("srv", "env:FROM_CHILD")).toBe("1");
     expect(await reader.get("srv", "env:FROM_PARENT")).toBe("2");
     expect(warnings()).toBe("");
-  }, 20_000);
+  });
 
   it("creates the storage directory so the very first save is locked too", async () => {
     // `writeStoreFile` creates the parent directory, but from *inside* the
@@ -232,7 +254,7 @@ describe("withSecretFileLock across processes", () => {
     expect(await reader.get("srv", "env:FIRST_EVER")).toBe("1");
     // No degrade warning: the lock was genuinely held, not skipped.
     expect(warnings()).toBe("");
-  }, 20_000);
+  });
 });
 
 describe("withSecretFileLock degrades rather than failing", () => {
@@ -263,131 +285,147 @@ describe("withSecretFileLock degrades rather than failing", () => {
     expect(warn).toHaveBeenCalledTimes(1);
   });
 
-  it("refuses the save rather than writing alongside a live holder", async () => {
-    // `ELOCKED` is evidence the lock is *working*, so degrading here would
-    // enter the exact interleaving the lock exists to prevent — and enter it
-    // knowing another writer is there. Held from this process, which is
-    // indistinguishable to `proper-lockfile` from a remote holder (it is not
-    // reentrant); the in-process queue is what keeps that out of the way in
-    // production.
-    //
-    // The wait is real: the retry budget deliberately outlasts the 10s stale
-    // window so a *crashed* holder resolves by takeover instead of failing
-    // everyone else's saves. This holder is alive and refreshing, so it never
-    // goes stale and the budget is spent in full.
-    const target = filePath();
-    const lockfile = require_(LOCKFILE_MODULE) as {
-      lock: (f: string, o: object) => Promise<() => Promise>;
-    };
-    // The **same** `stale` production uses, and that is not incidental:
-    // `isLockStale` is evaluated against the *waiter's* threshold while the
-    // holder refreshes on its own `stale / 2`. A holder configured looser
-    // (say 60s) refreshes every 30s and is therefore declared stale by a
-    // 10s waiter after 10s — the waiter takes over and the save succeeds,
-    // quietly testing the opposite of what this test claims.
-    const release = await lockfile.lock(target, {
-      realpath: false,
-      stale: 10_000,
-    });
+  it(
+    "refuses the save rather than writing alongside a live holder",
+    async () => {
+      // `ELOCKED` is evidence the lock is *working*, so degrading here would
+      // enter the exact interleaving the lock exists to prevent — and enter it
+      // knowing another writer is there. Held from this process, which is
+      // indistinguishable to `proper-lockfile` from a remote holder (it is not
+      // reentrant); the in-process queue is what keeps that out of the way in
+      // production.
+      //
+      // The wait is real: the retry budget deliberately outlasts the 10s stale
+      // window so a *crashed* holder resolves by takeover instead of failing
+      // everyone else's saves. This holder is alive and refreshing, so it never
+      // goes stale and the budget is spent in full.
+      const target = filePath();
+      const lockfile = require_(LOCKFILE_MODULE) as {
+        lock: (f: string, o: object) => Promise<() => Promise>;
+      };
+      // The **same** `stale` production uses, and that is not incidental:
+      // `isLockStale` is evaluated against the *waiter's* threshold while the
+      // holder refreshes on its own `stale / 2`. A holder configured looser
+      // (say 60s) refreshes every 30s and is therefore declared stale by a
+      // 10s waiter after 10s — the waiter takes over and the save succeeds,
+      // quietly testing the opposite of what this test claims.
+      const release = await lockfile.lock(target, {
+        realpath: false,
+        stale: 10_000,
+      });
 
-    const store = new FileSecretStore({ filePath: target });
-    // One call, both assertions off the same rejection: each attempt spends
-    // the full retry budget, so a second would double the test's runtime to
-    // re-prove the same thing.
-    const err = await store
-      .set("srv", "env:MINE", "1")
-      .catch((e: unknown) => e);
-    expect(err).toBeInstanceOf(SecretStoreUnavailableError);
-    expect((err as Error).message).toMatch(/was not saved/);
-    await release();
+      const store = new FileSecretStore({ filePath: target });
+      // One call, both assertions off the same rejection: each attempt spends
+      // the full retry budget, so a second would double the test's runtime to
+      // re-prove the same thing.
+      const err = await store
+        .set("srv", "env:MINE", "1")
+        .catch((e: unknown) => e);
+      expect(err).toBeInstanceOf(SecretStoreUnavailableError);
+      expect((err as Error).message).toMatch(/was not saved/);
+      await release();
 
-    // Nothing was written behind the holder's back.
-    expect(existsSync(target)).toBe(false);
-  }, 90_000);
+      // Nothing was written behind the holder's back.
+      expect(existsSync(target)).toBe(false);
+    },
+    LOCK_RETRIES_EXHAUSTED_MS,
+  );
 
-  it("refuses rather than degrading when a stale lock cannot be cleared", async () => {
-    // `acquireLock` does not only *create* directories — on finding a stale
-    // one it removes it and retries, and that removal can fail. A stale lock
-    // with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and
-    // treating every non-`ELOCKED` error as "locks do not work here" meant
-    // every Inspector on the box quietly bypassed the *same* stuck lock and
-    // raced its writes — while the release-failure message was telling the
-    // operator saves would keep failing until they cleared it.
-    const target = filePath();
-    const lockPath = `${target}.lock`;
-    await fs.mkdir(path.dirname(target), { recursive: true });
-    await fs.mkdir(lockPath);
-    await fs.writeFile(`${lockPath}/stray`, "", "utf-8");
-    // Backdated so it reads as stale — which is what sends `acquireLock` down
-    // the remove-and-retry path rather than straight to `ELOCKED`.
-    const longDead = new Date(Date.now() - 60_000);
-    await fs.utimes(lockPath, longDead, longDead);
+  it(
+    "refuses rather than degrading when a stale lock cannot be cleared",
+    async () => {
+      // `acquireLock` does not only *create* directories — on finding a stale
+      // one it removes it and retries, and that removal can fail. A stale lock
+      // with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and
+      // treating every non-`ELOCKED` error as "locks do not work here" meant
+      // every Inspector on the box quietly bypassed the *same* stuck lock and
+      // raced its writes — while the release-failure message was telling the
+      // operator saves would keep failing until they cleared it.
+      const target = filePath();
+      const lockPath = `${target}.lock`;
+      await fs.mkdir(path.dirname(target), { recursive: true });
+      await fs.mkdir(lockPath);
+      await fs.writeFile(`${lockPath}/stray`, "", "utf-8");
+      // Backdated so it reads as stale — which is what sends `acquireLock` down
+      // the remove-and-retry path rather than straight to `ELOCKED`.
+      const longDead = new Date(Date.now() - 60_000);
+      await fs.utimes(lockPath, longDead, longDead);
 
-    const store = new FileSecretStore({ filePath: target });
-    const err = await store
-      .set("srv", "env:MINE", "1")
-      .catch((e: unknown) => e);
+      const store = new FileSecretStore({ filePath: target });
+      const err = await store
+        .set("srv", "env:MINE", "1")
+        .catch((e: unknown) => e);
 
-    expect(err).toBeInstanceOf(SecretStoreUnavailableError);
-    expect((err as Error).message).toMatch(/was not saved/);
-    // Nothing written behind the stuck lock, and no "unprotected" warning:
-    // this is a refusal, not a degrade.
-    expect(existsSync(target)).toBe(false);
-    expect(warnings()).not.toContain("not protected");
-  }, 90_000);
+      expect(err).toBeInstanceOf(SecretStoreUnavailableError);
+      expect((err as Error).message).toMatch(/was not saved/);
+      // Nothing written behind the stuck lock, and no "unprotected" warning:
+      // this is a refusal, not a degrade.
+      expect(existsSync(target)).toBe(false);
+      expect(warnings()).not.toContain("not protected");
+    },
+    LOCK_RETRIES_EXHAUSTED_MS,
+  );
 
-  it("takes over the lock of a holder that died, rather than failing the save", async () => {
-    // The invariant behind refusing on `ELOCKED`: refusing is only defensible
-    // because a *crashed* holder resolves on its own first. `RETRY` therefore
-    // has to outlast `STALE_MS` — if the budget were the shorter of the two,
-    // one Inspector killed mid-save would make every later save on the box
-    // fail until someone deleted the lock by hand.
-    //
-    // A dead holder is exactly a lock directory nobody is refreshing, so it
-    // is staged directly: no child to race, and no dependence on how quickly
-    // a killed process is reaped.
-    const target = filePath();
-    const lockPath = `${target}.lock`;
-    await fs.mkdir(path.dirname(target), { recursive: true });
-    await fs.mkdir(lockPath);
-    const longDead = new Date(Date.now() - 60_000);
-    await fs.utimes(lockPath, longDead, longDead);
+  it(
+    "takes over the lock of a holder that died, rather than failing the save",
+    async () => {
+      // The invariant behind refusing on `ELOCKED`: refusing is only defensible
+      // because a *crashed* holder resolves on its own first. `RETRY` therefore
+      // has to outlast `STALE_MS` — if the budget were the shorter of the two,
+      // one Inspector killed mid-save would make every later save on the box
+      // fail until someone deleted the lock by hand.
+      //
+      // A dead holder is exactly a lock directory nobody is refreshing, so it
+      // is staged directly: no child to race, and no dependence on how quickly
+      // a killed process is reaped.
+      const target = filePath();
+      const lockPath = `${target}.lock`;
+      await fs.mkdir(path.dirname(target), { recursive: true });
+      await fs.mkdir(lockPath);
+      const longDead = new Date(Date.now() - 60_000);
+      await fs.utimes(lockPath, longDead, longDead);
 
-    const store = new FileSecretStore({ filePath: target });
-    await store.set("srv", "env:AFTER_CRASH", "1");
+      const store = new FileSecretStore({ filePath: target });
+      await store.set("srv", "env:AFTER_CRASH", "1");
 
-    const reader = new FileSecretStore({ filePath: target });
-    expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1");
-    // Took the lock over — did not fall through to an unlocked write.
-    expect(warnings()).toBe("");
-  }, 60_000);
+      const reader = new FileSecretStore({ filePath: target });
+      expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1");
+      // Took the lock over — did not fall through to an unlocked write.
+      expect(warnings()).toBe("");
+    },
+    STALE_LOCK_TAKEOVER_MS,
+  );
 
-  it("stays silent per the delete contract when the lock is held", async () => {
-    // `delete` reports nothing by contract — only `set` hard-fails — so the
-    // refusal above must not turn a delete into a throw.
-    const target = filePath();
-    const store = new FileSecretStore({ filePath: target });
-    await store.set("srv", "env:A", "1");
+  it(
+    "stays silent per the delete contract when the lock is held",
+    async () => {
+      // `delete` reports nothing by contract — only `set` hard-fails — so the
+      // refusal above must not turn a delete into a throw.
+      const target = filePath();
+      const store = new FileSecretStore({ filePath: target });
+      await store.set("srv", "env:A", "1");
 
-    const lockfile = require_(LOCKFILE_MODULE) as {
-      lock: (f: string, o: object) => Promise<() => Promise>;
-    };
-    // The **same** `stale` production uses, and that is not incidental:
-    // `isLockStale` is evaluated against the *waiter's* threshold while the
-    // holder refreshes on its own `stale / 2`. A holder configured looser
-    // (say 60s) refreshes every 30s and is therefore declared stale by a
-    // 10s waiter after 10s — the waiter takes over and the save succeeds,
-    // quietly testing the opposite of what this test claims.
-    const release = await lockfile.lock(target, {
-      realpath: false,
-      stale: 10_000,
-    });
-    await expect(store.delete("srv", "env:A")).resolves.toBeUndefined();
-    await release();
+      const lockfile = require_(LOCKFILE_MODULE) as {
+        lock: (f: string, o: object) => Promise<() => Promise>;
+      };
+      // The **same** `stale` production uses, and that is not incidental:
+      // `isLockStale` is evaluated against the *waiter's* threshold while the
+      // holder refreshes on its own `stale / 2`. A holder configured looser
+      // (say 60s) refreshes every 30s and is therefore declared stale by a
+      // 10s waiter after 10s — the waiter takes over and the save succeeds,
+      // quietly testing the opposite of what this test claims.
+      const release = await lockfile.lock(target, {
+        realpath: false,
+        stale: 10_000,
+      });
+      await expect(store.delete("srv", "env:A")).resolves.toBeUndefined();
+      await release();
 
-    // …and the entry it could not delete is still there, not half-removed.
-    expect(await store.get("srv", "env:A")).toBe("1");
-  }, 90_000);
+      // …and the entry it could not delete is still there, not half-removed.
+      expect(await store.get("srv", "env:A")).toBe("1");
+    },
+    LOCK_RETRIES_EXHAUSTED_MS,
+  );
 });
 
 describe("withSecretFileLock reports what it cannot clean up", () => {
@@ -457,7 +495,7 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
 
     // Unguarded, the winner's directory is gone.
     expect(existsSync(lockPath)).toBe(false);
-  }, 30_000);
+  });
 
   it("tells the operator to clear a lock that cannot expire on its own", async () => {
     // `rmdir` refuses a non-empty directory — and so does stale takeover,
@@ -510,5 +548,5 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
     // that did nothing wrong. `onCompromised` has already said what happened.
     expect(warnings()).not.toContain("by hand");
     expect(warnings()).not.toContain("Could not release the lock");
-  }, 30_000);
+  });
 });
diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts
index 9cc2b065..e560a479 100644
--- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts
+++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts
@@ -82,6 +82,15 @@ async function loadWithProbe(available: boolean, detail = "no D-Bus session") {
   return import("@inspector/core/auth/node/secret-store-selection.js");
 }
 
+/**
+ * Past the integration project's own 30s deliberately, and one of the few sites
+ * that should be (#2323): the test using this waits on a second process holding
+ * the secrets-file lock until `proper-lockfile`'s retries run out, which is real
+ * elapsed contention rather than slack for a loaded machine. Named to match
+ * `file-lock.test.ts`, which bounds the same wait.
+ */
+const LOCK_RETRIES_EXHAUSTED_MS = 60_000;
+
 describe("parseSecretStoreEnv", () => {
   it("accepts each kind, case- and whitespace-insensitively", () => {
     expect(parseSecretStoreEnv("keyring")).toBe("keyring");
@@ -684,38 +693,42 @@ describe("absorbFileSecretsIntoKeyring", () => {
     return filePath;
   }
 
-  it("does not throw when another process holds the lock (#2082)", async () => {
-    // `withSecretFileLock` throws on a lock held past its retry budget, which
-    // is right for a `set` — the user is waiting on that value — and wrong
-    // here. This function is awaited directly by both `resolveSecretStore`
-    // branches, so an escaping error fails store resolution and with it the
-    // whole session: a stuck writer elsewhere on the box would stop the
-    // Inspector from starting. Leaving the file for the next run is the only
-    // acceptable outcome.
-    const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
-    process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
-    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-    const mod = await loadWithProbe(true);
+  it(
+    "does not throw when another process holds the lock (#2082)",
+    async () => {
+      // `withSecretFileLock` throws on a lock held past its retry budget, which
+      // is right for a `set` — the user is waiting on that value — and wrong
+      // here. This function is awaited directly by both `resolveSecretStore`
+      // branches, so an escaping error fails store resolution and with it the
+      // whole session: a stuck writer elsewhere on the box would stop the
+      // Inspector from starting. Leaving the file for the next run is the only
+      // acceptable outcome.
+      const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
+      process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
+      const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+      const mod = await loadWithProbe(true);
 
-    const properLockfile = (await import("proper-lockfile")).default;
-    const release = await properLockfile.lock(filePath, {
-      realpath: false,
-      stale: 10_000,
-    });
+      const properLockfile = (await import("proper-lockfile")).default;
+      const release = await properLockfile.lock(filePath, {
+        realpath: false,
+        stale: 10_000,
+      });
 
-    await expect(
-      mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore()),
-    ).resolves.toBeUndefined();
-    await release();
+      await expect(
+        mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore()),
+      ).resolves.toBeUndefined();
+      await release();
 
-    // Left exactly as it was, and said so — asserting the *lock* message
-    // specifically, since the pre-existing claim-failure warning also ends in
-    // "left in place" and would let this pass without the lock ever being hit.
-    expect(existsSync(filePath)).toBe(true);
-    expect(warn).toHaveBeenCalledWith(
-      expect.stringContaining("Could not lock the secrets file"),
-    );
-  }, 60_000);
+      // Left exactly as it was, and said so — asserting the *lock* message
+      // specifically, since the pre-existing claim-failure warning also ends in
+      // "left in place" and would let this pass without the lock ever being hit.
+      expect(existsSync(filePath)).toBe(true);
+      expect(warn).toHaveBeenCalledWith(
+        expect.stringContaining("Could not lock the secrets file"),
+      );
+    },
+    LOCK_RETRIES_EXHAUSTED_MS,
+  );
 
   it("takes no lock when there is nothing to migrate (#2082)", async () => {
     // The overwhelmingly common startup: a keychain is available and no file
diff --git a/clients/web/src/test/integration/auth/oauth-stalled-endpoint.test.ts b/clients/web/src/test/integration/auth/oauth-stalled-endpoint.test.ts
new file mode 100644
index 00000000..549e74d0
--- /dev/null
+++ b/clients/web/src/test/integration/auth/oauth-stalled-endpoint.test.ts
@@ -0,0 +1,745 @@
+import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
+import path from "node:path";
+import type { Request, Response } from "express";
+import { fileURLToPath } from "node:url";
+import {
+  withOAuthRequestTimeout,
+  OAuthRequestTimeoutError,
+} from "@inspector/core/auth/requestTimeout.js";
+import {
+  createTestServerHttp,
+  type TestServerHttp,
+  createTestServerInfo,
+  createEchoTool,
+  loadConfig,
+  resolveConfig,
+  STALLABLE_OAUTH_ENDPOINTS,
+  type StallableOAuthEndpoint,
+  createOAuthStallMiddleware,
+  createStallRegistry,
+  MAX_STALL_MS,
+} from "@modelcontextprotocol/inspector-test-server";
+
+/**
+ * The OAuth-path request timeouts (#2319), driven against a **real, established,
+ * idle socket** rather than a `fetch` stub (#2382).
+ *
+ * #2319 put an `AbortSignal.timeout` on five OAuth calls that previously went
+ * out with no signal at all. Every test of that work injected a stubbed
+ * `fetchFn`, which settles on the *client* side — so the state the issue
+ * actually describes was never reproduced:
+ *
+ * > a stall is invisible in browser devtools […] while a Node-side socket sat
+ * > established and idle.
+ *
+ * These tests use the `oauth.stallEndpoints` fixture capability to accept the
+ * request and withhold the response, so the deadline is the only thing that can
+ * end the call. A regression that dropped the wrapper would hang here until the
+ * suite's own budget killed it, rather than passing against an obliging stub.
+ */
+const configsDir = path.resolve(
+  path.dirname(fileURLToPath(import.meta.url)),
+  "../../../../../../test-servers/configs",
+);
+
+/** Comfortably under the suite budget, comfortably over a LAN round trip. */
+const BUDGET_MS = 1_500;
+
+/** How each endpoint is requested, so every advertised name is really driven. */
+const ENDPOINTS: Record<
+  StallableOAuthEndpoint,
+  { pathname: string; init: RequestInit; query?: Record }
+> = {
+  "protected-resource-metadata": {
+    pathname: "/.well-known/oauth-protected-resource",
+    init: { method: "GET" },
+  },
+  "as-metadata": {
+    pathname: "/.well-known/oauth-authorization-server",
+    init: { method: "GET" },
+  },
+  authorize: {
+    pathname: "/oauth/authorize",
+    init: { method: "GET" },
+    // The one endpoint always called WITH a query string — which is what makes
+    // the middleware's `req.path` match load-bearing. Matching `req.url`
+    // instead passes every other case in this file and fails only here.
+    query: {
+      client_id: "test-client",
+      response_type: "code",
+      redirect_uri: "http://127.0.0.1:6274/oauth/callback",
+    },
+  },
+  token: {
+    pathname: "/oauth/token",
+    init: {
+      method: "POST",
+      headers: { "content-type": "application/x-www-form-urlencoded" },
+      body: "grant_type=authorization_code&code=irrelevant",
+    },
+  },
+  revoke: {
+    pathname: "/oauth/revoke",
+    init: {
+      method: "POST",
+      headers: { "content-type": "application/x-www-form-urlencoded" },
+      body: "token=irrelevant",
+    },
+  },
+  register: {
+    pathname: "/oauth/register",
+    init: {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:6274/cb"] }),
+    },
+  },
+};
+
+/** Wait for `predicate`, rather than sleeping and hoping. */
+async function until(
+  predicate: () => boolean,
+  what: string,
+  timeoutMs = 5_000,
+): Promise {
+  const deadline = Date.now() + timeoutMs;
+  while (Date.now() < deadline) {
+    if (predicate()) return;
+    await new Promise((resolve) => setTimeout(resolve, 10));
+  }
+  throw new Error(`timed out waiting for ${what}`);
+}
+
+describe("OAuth request timeouts against a stalled endpoint (#2382)", () => {
+  let server: TestServerHttp | null = null;
+
+  afterEach(async () => {
+    if (server) {
+      try {
+        await server.stop();
+      } catch {
+        // ignore
+      }
+      server = null;
+    }
+  });
+
+  function urlFor(base: string, endpoint: StallableOAuthEndpoint): string {
+    const { pathname, query } = ENDPOINTS[endpoint];
+    const url = new URL(pathname, base);
+    for (const [key, value] of Object.entries(query ?? {})) {
+      url.searchParams.set(key, value);
+    }
+    return url.href;
+  }
+
+  async function startStalling(
+    endpoints: StallableOAuthEndpoint[],
+    extra: { stallMs?: number } = {},
+  ): Promise {
+    const started = createTestServerHttp({
+      serverInfo: createTestServerInfo("oauth-stall", "1.0.0"),
+      tools: [createEchoTool()],
+      oauth: {
+        enabled: true,
+        mode: "combined",
+        requireAuth: true,
+        scopesSupported: ["mcp"],
+        supportDCR: true,
+        stallEndpoints: endpoints,
+        ...extra,
+      },
+    });
+    await started.start();
+    server = started;
+    return started;
+  }
+
+  // ⚠️ Every advertised endpoint, not a sample. `stallTargetsFor` hardcodes a
+  // path and a method set per endpoint, so a typo in any entry produces a
+  // fixture that quietly answers normally — the precise failure this option
+  // exists to prevent, and one no other test would catch (Copilot).
+  for (const endpoint of STALLABLE_OAUTH_ENDPOINTS) {
+    it(`stalls the ${endpoint} endpoint until the deadline fires`, async () => {
+      const started = await startStalling([endpoint]);
+      const url = urlFor(started.url, endpoint);
+      const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
+      const startedAt = Date.now();
+
+      await expect(timedFetch(url, ENDPOINTS[endpoint].init)).rejects.toThrow(
+        OAuthRequestTimeoutError,
+      );
+
+      // The deadline ended it, not an instant connection failure: a refused
+      // port rejects in single-digit ms and would satisfy a bare
+      // `rejects.toThrow` while proving nothing about the timer.
+      expect(Date.now() - startedAt).toBeGreaterThanOrEqual(BUDGET_MS - 100);
+    });
+
+    it(`names the ${endpoint} endpoint and the budget it gave up on`, async () => {
+      const started = await startStalling([endpoint]);
+      const url = urlFor(started.url, endpoint);
+      const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
+
+      await expect(
+        timedFetch(url, ENDPOINTS[endpoint].init),
+      ).rejects.toMatchObject({
+        timeoutMs: BUDGET_MS,
+        url: expect.stringContaining(ENDPOINTS[endpoint].pathname),
+      });
+    });
+  }
+
+  it("stalls only the selected endpoint, leaving the others answering", async () => {
+    // The control. A fixture that stalled everything would make every test
+    // above pass while telling us nothing about which call was bounded.
+    const started = await startStalling(["token"]);
+    const metadata = await fetch(
+      urlFor(started.url, "protected-resource-metadata"),
+    );
+
+    expect(metadata.ok).toBe(true);
+    await expect(metadata.json()).resolves.toMatchObject({
+      authorization_servers: expect.any(Array),
+    });
+  });
+
+  it("distinguishes two calls that share a path by their method", async () => {
+    // ⚠️ Both configurable document paths are caller-supplied, so a config may
+    // point one at a path another endpoint already serves. Keyed on path alone,
+    // stalling `token` would also stall this GET (Copilot).
+    const started = await startStalling(["token"]);
+    const collided = new URL("/oauth/token", started.url).href;
+
+    // The token POST is stalled…
+    await expect(
+      withOAuthRequestTimeout(fetch, BUDGET_MS)(collided, ENDPOINTS.token.init),
+    ).rejects.toThrow(OAuthRequestTimeoutError);
+
+    // …while a GET to the very same path is not held by the stall middleware.
+    // Which status it gets does not matter; that a response arrives at all does.
+    const samePathGet = await fetch(collided, { method: "GET" });
+    expect(typeof samePathGet.status).toBe("number");
+  });
+
+  it("answers late rather than never when stallMs is positive", async () => {
+    // `stallMs` is advertised and was previously untested: every fixture used
+    // 0, so the `setTimeout(() => next())` branch could regress unseen
+    // (Copilot).
+    const delayMs = 600;
+    const started = await startStalling(["protected-resource-metadata"], {
+      stallMs: delayMs,
+    });
+    const startedAt = Date.now();
+
+    const res = await fetch(urlFor(started.url, "protected-resource-metadata"));
+    const elapsed = Date.now() - startedAt;
+
+    expect(res.ok).toBe(true);
+    // Late, but it did arrive — both halves matter.
+    expect(elapsed).toBeGreaterThanOrEqual(delayMs - 50);
+    await expect(res.json()).resolves.toMatchObject({
+      authorization_servers: expect.any(Array),
+    });
+  });
+
+  it("times out when the caller's budget is shorter than stallMs", async () => {
+    const started = await startStalling(["token"], { stallMs: 5_000 });
+    const timedFetch = withOAuthRequestTimeout(fetch, 400);
+
+    await expect(
+      timedFetch(urlFor(started.url, "token"), ENDPOINTS.token.init),
+    ).rejects.toThrow(OAuthRequestTimeoutError);
+  });
+
+  it("stops holding the request when the client aborts a delayed stall", async () => {
+    // End-to-end: the fixture lets go. This observes the REGISTRY, which is
+    // decremented by the `close` listener — so it does NOT prove the timer was
+    // cleared. That claim belongs to the focused test below, which can actually
+    // see the timer (Copilot).
+    const started = await startStalling(["token"], { stallMs: 10_000 });
+    const controller = new AbortController();
+
+    const pending = fetch(urlFor(started.url, "token"), {
+      ...ENDPOINTS.token.init,
+      signal: controller.signal,
+    }).catch(() => "aborted");
+
+    await until(
+      () => started.stalledRequestCount() >= 1,
+      "the request to be parked",
+    );
+    controller.abort();
+
+    await expect(pending).resolves.toBe("aborted");
+    await until(
+      () => started.stalledRequestCount() === 0,
+      "the stall to be released",
+    );
+  });
+
+  it("rejects an unknown stallEndpoints entry instead of answering normally", async () => {
+    // A typo must fail loudly. Ignored, it would produce a fixture that answers
+    // promptly, and a timeout test written against it would fail pointing at
+    // the timeout rather than at the config.
+    //
+    // The throw lands on `start()`, not on the constructor: routes — and so the
+    // middleware — are built when the server is started. Asserting on the
+    // constructor would pass for the wrong reason, since it validates nothing.
+    const typo = createTestServerHttp({
+      serverInfo: createTestServerInfo("oauth-stall-typo", "1.0.0"),
+      tools: [createEchoTool()],
+      oauth: {
+        enabled: true,
+        mode: "combined",
+        // @ts-expect-error - deliberately not a StallableOAuthEndpoint
+        stallEndpoints: ["tokens"],
+      },
+    });
+
+    await expect(typo.start()).rejects.toThrow(
+      /Unknown oauth\.stallEndpoints entry.*"tokens"/s,
+    );
+    // It must also name what WAS valid, or the fixture author is left guessing.
+    await expect(typo.start()).rejects.toThrow(/Expected one of:.*token/s);
+
+    try {
+      await typo.stop();
+    } catch {
+      // never started; nothing to stop
+    }
+  });
+
+  it("stops cleanly with a withheld response still in flight", async () => {
+    // ⚠️ The property that makes this fixture usable at all. A withheld
+    // response holds an established socket, and `stop()` relies on
+    // `httpServer.closeAllConnections?.()` to destroy it. Without that, every
+    // suite touching this fixture would hang at teardown instead of failing —
+    // and it would hang in `afterEach`, pointing at the wrong test.
+    const started = await startStalling(["token"]);
+
+    // Deliberately unawaited and unbounded: the point is that a request with no
+    // deadline of its own is in flight when the server goes down.
+    const pending = fetch(urlFor(started.url, "token"), {
+      ...ENDPOINTS.token.init,
+    }).catch(() => "socket destroyed");
+
+    // ⚠️ Wait for the request to be ACCEPTED AND PARKED, never a fixed sleep.
+    // A sleep that lost the race would stop the server before the request
+    // arrived; the fetch would then reject because the server closed, and this
+    // test would pass without ever exercising `closeAllConnections()` on an
+    // established request (Copilot).
+    await until(
+      () => started.stalledRequestCount() >= 1,
+      "the request to be parked",
+    );
+
+    await expect(started.stop()).resolves.toBeUndefined();
+    server = null;
+
+    await expect(pending).resolves.toBe("socket destroyed");
+  });
+
+  it("stalls the authorize POST, not only its GET", async () => {
+    // ⚠️ `authorize` is the only endpoint mapped to two methods, and the table
+    // above drives it as a GET. Without this, deleting `"POST"` from its method
+    // list passes the whole suite while a consent submission stops stalling
+    // (Copilot).
+    const started = await startStalling(["authorize"]);
+    const url = new URL("/oauth/authorize", started.url).href;
+    const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
+
+    await expect(
+      timedFetch(url, {
+        method: "POST",
+        headers: { "content-type": "application/x-www-form-urlencoded" },
+        body: "client_id=test-client&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A6274%2Foauth%2Fcallback",
+      }),
+    ).rejects.toThrow(OAuthRequestTimeoutError);
+  });
+
+  it("follows resourceMetadataPath when the document is moved", async () => {
+    // ⚠️ Both metadata paths are configurable, and the table above only ever
+    // requests the DEFAULTS. A regression replacing either lookup in
+    // `stallTargetsFor` with its default literal would leave every other test
+    // green while `stallEndpoints` silently stopped working for any config that
+    // moves the document (Copilot).
+    const moved = "/custom/protected-resource.json";
+    const started = createTestServerHttp({
+      serverInfo: createTestServerInfo("oauth-stall-moved-prm", "1.0.0"),
+      tools: [createEchoTool()],
+      oauth: {
+        enabled: true,
+        mode: "combined",
+        requireAuth: true,
+        scopesSupported: ["mcp"],
+        resourceMetadataPath: moved,
+        stallEndpoints: ["protected-resource-metadata"],
+      },
+    });
+    await started.start();
+    server = started;
+
+    await expect(
+      withOAuthRequestTimeout(
+        fetch,
+        BUDGET_MS,
+      )(new URL(moved, started.url).href),
+    ).rejects.toThrow(OAuthRequestTimeoutError);
+
+    // And the DEFAULT path is no longer the stalled one — it is not served at
+    // all once the document moves, so it answers (404) instead of hanging.
+    const atDefault = await fetch(
+      new URL("/.well-known/oauth-protected-resource", started.url).href,
+    );
+    expect(typeof atDefault.status).toBe("number");
+  });
+
+  it("follows asMetadataPath when the AS document is moved", async () => {
+    const moved = "/custom/as-metadata.json";
+    const started = createTestServerHttp({
+      serverInfo: createTestServerInfo("oauth-stall-moved-as", "1.0.0"),
+      tools: [createEchoTool()],
+      oauth: {
+        enabled: true,
+        mode: "combined",
+        requireAuth: true,
+        scopesSupported: ["mcp"],
+        asMetadataPath: moved,
+        stallEndpoints: ["as-metadata"],
+      },
+    });
+    await started.start();
+    server = started;
+
+    await expect(
+      withOAuthRequestTimeout(
+        fetch,
+        BUDGET_MS,
+      )(new URL(moved, started.url).href),
+    ).rejects.toThrow(OAuthRequestTimeoutError);
+  });
+
+  it("drives the two checked-in showcase configs, not just inline ones", async () => {
+    // The JSON-to-ServerConfig mapping is its own failure surface: a misspelled
+    // key in either file, or a `resolveConfig` that stopped threading
+    // `stallEndpoints`, would leave the documented manual fixtures quietly
+    // permissive while every inline test above stayed green.
+    for (const file of [
+      "oauth-stalled-token-http.json",
+      "oauth-stalled-discovery-http.json",
+    ]) {
+      const resolved = resolveConfig(loadConfig(path.join(configsDir, file)));
+      const started = createTestServerHttp(resolved);
+      await started.start();
+      server = started;
+
+      const endpoint: StallableOAuthEndpoint = file.includes("token")
+        ? "token"
+        : "protected-resource-metadata";
+      const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
+
+      await expect(
+        timedFetch(urlFor(started.url, endpoint), ENDPOINTS[endpoint].init),
+      ).rejects.toThrow(OAuthRequestTimeoutError);
+
+      await started.stop();
+      server = null;
+    }
+  });
+});
+
+/**
+ * The middleware on its own, with no server, so the timer itself is observable.
+ *
+ * The end-to-end abort test can only watch the registry, which a separate
+ * `close` listener decrements — so it passes with the timer still armed and
+ * cannot support a claim about `clearTimeout` (Copilot). Here `next` is a spy
+ * and time is fake, so "the delayed answer never fires after a close" is
+ * directly checkable.
+ */
+describe("createOAuthStallMiddleware timer cleanup (#2382)", () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  /**
+   * Minimal Express doubles, bridged to the real types.
+   *
+   * The middleware touches exactly three things — `req.method`, `req.path` and
+   * `res.on("close", …)` — so a full `Request`/`Response` is unnecessary and a
+   * real server would defeat the purpose (the whole point here is to control
+   * time and observe `next`).
+   *
+   * ⚠️ The bridge is a justified double cast, NOT an `any`: this repo forbids
+   * `any` outright and forbids disabling the rule to satisfy the linter
+   * (`AGENTS.md`, Typescript instructions). A single `as Request` cannot work —
+   * the literal is missing ~50 properties, so TS rejects the conversion — and
+   * these doubles are structurally correct for every field the code under test
+   * reads. If the middleware ever reads more, the double will be wrong at
+   * runtime rather than silently absorbing it, which is what an `any` would do
+   * (Copilot).
+   */
+  function fakeRes(): { res: Response; close: () => void } {
+    const listeners: Array<() => void> = [];
+    const double = {
+      on(event: string, fn: () => void) {
+        if (event === "close") listeners.push(fn);
+        return double;
+      },
+    };
+    return {
+      res: double as unknown as Response,
+      close: () => listeners.forEach((fn) => fn()),
+    };
+  }
+
+  function fakeReq(method: string, path: string): Request {
+    return { method, path } as unknown as Request;
+  }
+
+  const tokenReq = fakeReq("POST", "/oauth/token");
+
+  it("does not answer a delayed stall after the client closed", () => {
+    const middleware = createOAuthStallMiddleware(
+      {
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        stallMs: 10_000,
+      },
+      createStallRegistry(),
+    );
+    expect(middleware).not.toBeNull();
+
+    const next = vi.fn();
+    const { res, close } = fakeRes();
+    middleware!(tokenReq, res, next);
+
+    expect(next).not.toHaveBeenCalled();
+    close();
+
+    // Past the delay, with the timer cleared: nothing should fire. Without
+    // `clearTimeout` this advances into `next()` and the spy is called.
+    vi.advanceTimersByTime(20_000);
+    expect(next).not.toHaveBeenCalled();
+  });
+
+  it("still answers a delayed stall that is not closed", () => {
+    // The complement — otherwise a middleware that never calls `next` at all
+    // would satisfy the test above.
+    const middleware = createOAuthStallMiddleware(
+      {
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        stallMs: 10_000,
+      },
+      createStallRegistry(),
+    );
+    const next = vi.fn();
+    const { res } = fakeRes();
+    middleware!(tokenReq, res, next);
+
+    vi.advanceTimersByTime(9_999);
+    expect(next).not.toHaveBeenCalled();
+    vi.advanceTimersByTime(2);
+    expect(next).toHaveBeenCalledTimes(1);
+  });
+
+  it("rejects a malformed stallEndpoints container instead of disabling silently", () => {
+    // ⚠️ The container, not just its entries. A config file is cast, so
+    // `stallEndpoints` can arrive as a string or an object with a `length`. A
+    // bare `.length === 0` check accepts `""` and `{ length: 0 }` and returns
+    // "no stalling configured" — silently disabling the very capability this
+    // validation exists to protect (Copilot).
+    for (const stallEndpoints of ["", "token", null, { length: 0 }, 3]) {
+      expect(() =>
+        createOAuthStallMiddleware({
+          enabled: true,
+          mode: "combined",
+          // @ts-expect-error - a JSON config is cast, so this really can arrive
+          stallEndpoints,
+        }),
+      ).toThrow(/oauth\.stallEndpoints must be an array/);
+    }
+
+    // An omitted value is not malformed — it means "no stalling".
+    expect(
+      createOAuthStallMiddleware({ enabled: true, mode: "combined" }),
+    ).toBeNull();
+    // Nor is an explicitly empty array.
+    expect(
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: [],
+      }),
+    ).toBeNull();
+  });
+
+  it("refuses to stall a route this config does not serve", () => {
+    // ⚠️ The middleware runs BEFORE Express routing, so without this it will
+    // hold a request for an endpoint that would otherwise 404 — turning a
+    // contradictory fixture into a hang that reads as a timeout, and inviting a
+    // test that passes for entirely the wrong reason (Copilot).
+    const cases: Array<[Record, StallableOAuthEndpoint]> = [
+      // DCR off, but asked to stall the registration endpoint.
+      [{ mode: "combined", supportDCR: false }, "register"],
+      // Revocation explicitly disabled.
+      [{ mode: "combined", supportRevocation: false }, "revoke"],
+      // protected-resource mode serves no local AS routes at all.
+      [
+        {
+          mode: "protected-resource",
+          authorizationServers: ["https://as.example"],
+        },
+        "token",
+      ],
+      [
+        {
+          mode: "protected-resource",
+          authorizationServers: ["https://as.example"],
+        },
+        "authorize",
+      ],
+      [
+        {
+          mode: "protected-resource",
+          authorizationServers: ["https://as.example"],
+        },
+        "as-metadata",
+      ],
+    ];
+
+    for (const [oauth, endpoint] of cases) {
+      expect(() =>
+        createOAuthStallMiddleware({
+          enabled: true,
+          ...oauth,
+          stallEndpoints: [endpoint],
+        }),
+      ).toThrow(/does not serve/);
+    }
+
+    // The protected-resource document is served in every mode, so stalling it
+    // is always legitimate — the check must not over-reject.
+    expect(
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "protected-resource",
+        authorizationServers: ["https://as.example"],
+        stallEndpoints: ["protected-resource-metadata"],
+      }),
+    ).not.toBeNull();
+    // And the enabled forms are accepted.
+    expect(
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        supportDCR: true,
+        stallEndpoints: ["register", "revoke", "token"],
+      }),
+    ).not.toBeNull();
+  });
+
+  it("names the offending value honestly, including NaN and Infinity", () => {
+    // ⚠️ `JSON.stringify(NaN)` is the string "null", so an error built with it
+    // reports a value the author never wrote (Copilot).
+    expect(() =>
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        stallMs: Number.NaN,
+      }),
+    ).toThrow(/got NaN/);
+    expect(() =>
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        stallMs: Infinity,
+      }),
+    ).toThrow(/got Infinity/);
+    // A string keeps its quotes, so "600" stays distinguishable from 600.
+    expect(() =>
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        // @ts-expect-error - a JSON config is cast, so this really can arrive
+        stallMs: "600",
+      }),
+    ).toThrow(/got "600"/);
+  });
+
+  it("rejects an explicit stallMs: null rather than defaulting it to 0", () => {
+    // ⚠️ `?? 0` would turn `null` into a valid 0 and skip every check, silently
+    // producing a permanent stall. Only an OMITTED value gets the default
+    // (Copilot).
+    expect(() =>
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        // @ts-expect-error - a JSON config is cast, so this really can arrive
+        stallMs: null,
+      }),
+    ).toThrow(/oauth\.stallMs must be a finite number/);
+
+    // Omitted still means 0 (stall forever), which is the documented default.
+    expect(
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+      }),
+    ).not.toBeNull();
+  });
+
+  it("rejects a stallMs that is negative, non-finite or past the timer range", () => {
+    // ⚠️ A JSON/YAML config is only CAST to its interface, so anything can
+    // arrive here. Unvalidated, a negative value makes `stallMs > 0` false and
+    // silently becomes a PERMANENT stall, and a value past the 32-bit timer
+    // range overflows to ~1ms and answers almost immediately — both read as
+    // "the timeout behaved strangely" rather than "the config is wrong"
+    // (Copilot).
+    for (const stallMs of [-1, Number.NaN, Infinity, MAX_STALL_MS + 1]) {
+      expect(() =>
+        createOAuthStallMiddleware({
+          enabled: true,
+          mode: "combined",
+          stallEndpoints: ["token"],
+          stallMs,
+        }),
+      ).toThrow(/oauth\.stallMs must be a finite number/);
+    }
+    // A non-numeric value from an unvalidated config file.
+    expect(() =>
+      createOAuthStallMiddleware({
+        enabled: true,
+        mode: "combined",
+        stallEndpoints: ["token"],
+        // @ts-expect-error - a JSON config is cast, so this really can arrive
+        stallMs: "600",
+      }),
+    ).toThrow(/oauth\.stallMs must be a finite number/);
+
+    // The boundaries themselves are valid.
+    for (const stallMs of [0, MAX_STALL_MS]) {
+      expect(() =>
+        createOAuthStallMiddleware({
+          enabled: true,
+          mode: "combined",
+          stallEndpoints: ["token"],
+          stallMs,
+        }),
+      ).not.toThrow();
+    }
+  });
+});
diff --git a/clients/web/src/test/integration/auth/revocation-e2e.test.ts b/clients/web/src/test/integration/auth/revocation-e2e.test.ts
index dfbd4d0c..646cf99b 100644
--- a/clients/web/src/test/integration/auth/revocation-e2e.test.ts
+++ b/clients/web/src/test/integration/auth/revocation-e2e.test.ts
@@ -96,7 +96,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
     metadata = (await (
       await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
     ).json()) as OAuthMetadata;
-  }, 30_000);
+  });
 
   afterAll(async () => {
     await mcpServer?.stop();
@@ -105,7 +105,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
       rmSync(storageDir, { recursive: true, force: true });
       storageDir = "";
     }
-  }, 30_000);
+  });
 
   /** Run a real authorization-code exchange and return the issued tokens. */
   async function authorize(
diff --git a/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts b/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts
index cbd06e52..353123e6 100644
--- a/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts
+++ b/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts
@@ -51,12 +51,12 @@ describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
     const port = await mcpServer.start();
     serverUrl = `http://localhost:${port}`;
     await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH });
-  }, 30_000);
+  });
 
   afterAll(async () => {
     await mcpServer?.stop();
     mcpServer = null;
-  }, 30_000);
+  });
 
   it("serves plain RFC 8414 metadata only from the OIDC path", async () => {
     const oidc = await fetch(`${serverUrl}${OIDC_PATH}`);
diff --git a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts
index e185daa5..ef486658 100644
--- a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts
+++ b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts
@@ -11,15 +11,19 @@ import {
 } from "@inspector/core/mcp/extensions.js";
 
 /**
- * Drift guard for the MCP Apps UI advertisement (#1740). `MCP_APP_MIME_TYPE` is
- * hardcoded in core (ext-apps' constant lives on a `/server` subpath / an
- * extensionless re-export that doesn't resolve cleanly under NodeNext in the
- * browser build). A conforming server checks the client's advertised `mimeTypes`
- * before serving an App, so if the hardcoded string ever drifts from ext-apps'
- * real `RESOURCE_MIME_TYPE`, Apps silently stop working against strict servers.
+ * Drift guard for the MCP Apps UI advertisement (#1740). `UI_EXTENSION_KEY` is
+ * hardcoded in core (ext-apps' `EXTENSION_ID` still lives only on the `/server`
+ * subpath as of 2.0.0, which the browser build must not import). A conforming
+ * server keys its Apps lookup on the extension id and checks the client's
+ * advertised `mimeTypes` before serving an App, so a drifted string silently
+ * stops Apps working against strict servers.
  *
- * This runs in the node integration project, where importing the real ext-apps
- * value resolves — the one place the two can actually be compared.
+ * `MCP_APP_MIME_TYPE` is no longer a copy — since ext-apps 2.0.0 core re-exports
+ * `RESOURCE_MIME_TYPE` (#1745) — so its case here is a re-export check rather
+ * than a drift check; it stays so the advertisement is asserted end to end.
+ *
+ * This runs in the node integration project, where importing the `/server`
+ * value resolves — the one place the extension id can actually be compared.
  */
 describe("MCP Apps UI extension constants (#1740)", () => {
   it("MCP_APP_MIME_TYPE matches ext-apps' RESOURCE_MIME_TYPE exactly", () => {
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts
index 97ca51c4..fc7f2c44 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts
@@ -117,8 +117,9 @@ describe("advertised-extension gating (#1739)", () => {
 
   it("omits the gated tool when the extension is not advertised", async () => {
     const started = await startGatedServer();
-    // Disable the only registry extension → the client sends no extensions,
-    // so the server never enables the gated tool.
+    // Disable the Tasks extension → the client still sends the other registry
+    // extensions (MCP Apps UI, Skills), but not the one this tool is gated on,
+    // so the server never enables it.
     const connected = await connect(started.url, {
       [TASKS_EXTENSION_KEY]: false,
     });
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts
index d0067baf..afbdff41 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts
@@ -24,7 +24,11 @@ import {
   createNumberedPrompts,
 } from "@modelcontextprotocol/inspector-test-server";
 import type { Tool } from "@modelcontextprotocol/client";
-import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
+import {
+  Client,
+  ProtocolError,
+  ProtocolErrorCode,
+} from "@modelcontextprotocol/client";
 
 const serverCommand = getTestMcpServerCommand();
 
@@ -114,26 +118,17 @@ describe("InspectorClient coverage backfill", () => {
     });
   });
 
-  describe("getAppRendererClient proxy", () => {
-    it("returns null before connect and a memoized proxy after connect", async () => {
+  describe("getAppRendererClient", () => {
+    it("returns null before connect and the SDK client after connect", async () => {
       client = stdioClient();
       // Not connected yet → null.
       expect(client.getAppRendererClient()).toBeNull();
 
       await client.connect();
-      const proxy = client.getAppRendererClient();
-      expect(proxy).not.toBeNull();
-      // Second call returns the same memoized proxy.
-      expect(client.getAppRendererClient()).toBe(proxy);
-
-      // Accessing setNotificationHandler returns the wrapped function (covers
-      // the prop === "setNotificationHandler" branch), and accessing another
-      // prop returns the underlying value (covers the fall-through return).
-      const wrapped = (proxy as unknown as { setNotificationHandler: unknown })
-        .setNotificationHandler;
-      expect(typeof wrapped).toBe("function");
-      const other = (proxy as unknown as { request: unknown }).request;
-      expect(typeof other).toBe("function");
+      const appClient = client.getAppRendererClient();
+      expect(appClient).toBeInstanceOf(Client);
+      // Same instance on every call — it is the client, not a wrapper (#1745).
+      expect(client.getAppRendererClient()).toBe(appClient);
     });
   });
 
@@ -885,10 +880,11 @@ describe("InspectorClient coverage backfill", () => {
   });
 
   describe("constructor capability branches and createReceiverTask options", () => {
-    it("advertises only the tasks extension when no other capabilities are set", async () => {
+    it("still advertises the registry extensions when no other capabilities are set", async () => {
       // sample:false + elicit:false + no roots + no receiverTasks → the only
-      // advertised capability is the always-on Tasks extension (#1631), so
-      // `capabilities` is non-empty and is always attached.
+      // advertised capabilities are the default-on registry extensions (Tasks
+      // since #1631, Skills since #2373), so `capabilities` is non-empty and
+      // is always attached.
       client = new InspectorClient(
         {
           type: "stdio",
@@ -906,6 +902,7 @@ describe("InspectorClient coverage backfill", () => {
       expect(caps.elicitation).toBeUndefined();
       expect(caps.roots).toBeUndefined();
       expect(caps.extensions?.["io.modelcontextprotocol/tasks"]).toBeDefined();
+      expect(caps.extensions?.["io.modelcontextprotocol/skills"]).toBeDefined();
       await client.connect();
       expect(client.getStatus()).toBe("connected");
     });
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts
index d7a91274..f952a930 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts
@@ -70,7 +70,7 @@ describe("OAuth connect on the probing eras (#1805)", () => {
       await mcpServer.stop();
       mcpServer = null;
     }
-  }, 30_000);
+  });
 
   afterAll(() => {
     try {
@@ -166,7 +166,7 @@ describe("OAuth connect on the probing eras (#1805)", () => {
       expect(
         (error as AuthRecoveryRequiredError).authorizationUrl,
       ).toBeInstanceOf(URL);
-    }, 30_000);
+    });
 
     it(`connects on the "${era}" era once authorization completes`, async () => {
       const serverUrl = await startProtectedModernServer();
@@ -183,6 +183,6 @@ describe("OAuth connect on the probing eras (#1805)", () => {
       expect(authorizing.getProtocolEra()).toBe("modern");
       expect(authorizing.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION);
       expect((await authorizing.listTools()).tools.length).toBeGreaterThan(0);
-    }, 30_000);
+    });
   }
 });
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts
index 83e15acb..5c11dcb7 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts
@@ -493,7 +493,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
 
     await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
     await expect(aborted).resolves.toBe(true);
-  }, 30_000);
+  });
 
   // The test above builds its own never-returning tool, which is the sharpest
   // way to assert on the server's abort signal but says nothing about the
@@ -576,7 +576,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
     // The handler must return promptly, saying it was cancelled — not run on to
     // its 30th second, which is what it did before the fix.
     await expect(outcome).resolves.toContain("cancelled after");
-  }, 30_000);
+  });
 
   it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
     const started = await startMrtrServer(createMrtrTool());
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts
index be981518..5f96b788 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts
@@ -54,7 +54,7 @@ describe("InspectorClient direct mid-session OAuth", () => {
       await mcpServer.stop();
       mcpServer = null;
     }
-  }, 30_000);
+  });
 
   afterAll(() => {
     try {
@@ -139,7 +139,7 @@ describe("InspectorClient direct mid-session OAuth", () => {
     expect(client.getStatus()).toBe("connected");
 
     await client.disconnect();
-  }, 30_000);
+  });
 
   it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => {
     const staticClientId = "test-direct-step-up";
@@ -244,5 +244,5 @@ describe("InspectorClient direct mid-session OAuth", () => {
     expect(result.success).toBe(true);
 
     await client.disconnect();
-  }, 30_000);
+  });
 });
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts
index 4cde2b6b..abc8bb47 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts
@@ -104,7 +104,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
     }
     remoteBaseUrl = undefined;
     remoteAuthToken = undefined;
-  }, 30_000);
+  });
 
   afterAll(() => {
     try {
@@ -206,7 +206,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
     expect(client.getStatus()).toBe("connected");
 
     await client.disconnect();
-  }, 15_000);
+  });
 
   it("recovers on reconnect when stored access token was invalidated before connect", async () => {
     await setupRemoteServer();
@@ -446,7 +446,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
 
     await callbackClient.disconnect();
     await client.disconnect();
-  }, 30_000);
+  });
 
   it("resumeAfterOAuth falls back to connect when remote session is dead", async () => {
     await setupRemoteServer();
@@ -549,7 +549,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
 
     await callbackClient.disconnect();
     await client.disconnect();
-  }, 30_000);
+  });
 
   it("dispatches authChallengeInteractive for ambient interactive recovery", async () => {
     await setupRemoteServer();
@@ -640,7 +640,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
     ).toBeInstanceOf(URL);
 
     await client.disconnect();
-  }, 15_000);
+  });
 
   it("recovers from ambient auth notification after idle token invalidation", async () => {
     await setupRemoteServer();
@@ -731,5 +731,5 @@ describe("InspectorClient remote mid-session OAuth", () => {
     expect(client.getStatus()).toBe("connected");
 
     await client.disconnect();
-  }, 15_000);
+  });
 });
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts
index 7bbf03c2..fb7dc821 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts
@@ -1,6 +1,7 @@
 import { describe, it, expect, afterEach } from "vitest";
 import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
 import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
+import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
 import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
 import { ManagedSkillsState } from "@inspector/core/mcp/state/managedSkillsState.js";
 import {
@@ -55,7 +56,10 @@ describe("Skills extension over a real transport (#2234)", () => {
     }
   });
 
-  async function startSkillsServer(modern: boolean): Promise {
+  async function startSkillsServer(
+    modern: boolean,
+    requireClientExtension = false,
+  ): Promise {
     const started = createTestServerHttp({
       serverInfo: createTestServerInfo("skills-integration", "1.0.0"),
       // An ordinary resource alongside the skills, so the fixture's
@@ -69,6 +73,7 @@ describe("Skills extension over a real transport (#2234)", () => {
         },
       ],
       skills: true,
+      ...(requireClientExtension && { skillsRequireClientExtension: true }),
       ...(modern && { modern: {} }),
     });
     await started.start();
@@ -79,20 +84,107 @@ describe("Skills extension over a real transport (#2234)", () => {
   async function connect(
     url: string,
     modern: boolean,
+    advertisedExtensions?: Record,
   ): Promise {
+    // The era is chosen by `versionNegotiation`. This helper used to set
+    // `protocolEra` on the transport config instead, which `InspectorClient`
+    // does not read — so every "modern" case below connected on legacy and
+    // passed without exercising the modern leg at all (#2373). The assertion
+    // after connect is what keeps that from recurring silently.
     const connected = new InspectorClient(
+      { type: "streamable-http", url },
       {
-        type: "streamable-http",
-        url,
-        ...(modern && { protocolEra: "modern" as const }),
+        environment: { transport: createTransportNode },
+        versionNegotiation: eraToVersionNegotiation(
+          modern ? "modern" : "legacy",
+        ),
+        advertisedExtensions,
       },
-      { environment: { transport: createTransportNode } },
     );
     await connected.connect();
     client = connected;
+    expect(connected.getProtocolEra()).toBe(modern ? "modern" : "legacy");
     return connected;
   }
 
+  /**
+   * #2373: SEP-2133 negotiates an extension from both sides, and a strict
+   * server refuses `skills/*` to a client that did not declare
+   * `io.modelcontextprotocol/skills` itself. The Inspector never declared it,
+   * and every test above passed anyway because the default fixture serves any
+   * client. These run against the strict fixture on both eras — the legacy leg
+   * reads the declaration from `initialize`, the modern leg from each
+   * request's `_meta` envelope, so each is a separate path to prove.
+   */
+  for (const modern of [false, true]) {
+    const era = modern ? "modern" : "legacy";
+
+    describe(`against a server that requires the client's declaration (${era})`, () => {
+      it("is served, because the Inspector declares the extension by default", async () => {
+        const started = await startSkillsServer(modern, true);
+        const connected = await connect(started.url, modern);
+        const first = await connected.listSkills();
+        expect(first.skills).toHaveLength(2);
+        const entry = await connected.getSkill(
+          "skill://data-analysis/SKILL.md",
+        );
+        expect(entry.frontmatter.name).toBe("data-analysis");
+        const page = await connected.readResourceDirectory(
+          "skill://data-analysis",
+        );
+        expect(page.resources).toHaveLength(1);
+      });
+
+      it("is refused once the declaration is turned off", async () => {
+        // The negative control: without it the test above could pass against
+        // a fixture that stopped checking. It is also the Server Settings
+        // toggle's whole purpose — reproducing a strict server's refusal.
+        const started = await startSkillsServer(modern, true);
+        const connected = await connect(started.url, modern, {
+          "io.modelcontextprotocol/skills": false,
+        });
+        // The wire code is the assertion, not just the message: the two eras
+        // name this refusal differently, and a fixture sliding back to a
+        // semantically different error must fail here. Modern is SEP-2575's
+        // `-32021` MissingRequiredClientCapability listing the missing
+        // extension; legacy has no such code and stays `-32601`.
+        const expected = modern
+          ? {
+              code: -32021,
+              data: {
+                requiredCapabilities: {
+                  extensions: { "io.modelcontextprotocol/skills": {} },
+                },
+              },
+            }
+          : { code: -32601 };
+        // Thunks, not promises: starting all three up front leaves the later
+        // rejections unhandled while the first is awaited, which fails the
+        // run even though every assertion passes.
+        const refusals = [
+          () => connected.listSkills(),
+          () => connected.getSkill("skill://data-analysis/SKILL.md"),
+          () => connected.readResourceDirectory("skill://data-analysis"),
+        ];
+        for (const refusal of refusals) {
+          await expect(refusal()).rejects.toMatchObject({
+            ...expected,
+            message: expect.stringMatching(
+              /requires the client to declare io\.modelcontextprotocol\/skills/,
+            ),
+          });
+        }
+        // A skill file is an ordinary resource and stays readable.
+        const read = await connected.readResource(
+          "skill://data-analysis/reference.md",
+        );
+        expect(read.result.contents[0].uri).toBe(
+          "skill://data-analysis/reference.md",
+        );
+      });
+    });
+  }
+
   for (const modern of [false, true]) {
     const era = modern ? "modern" : "legacy";
 
@@ -114,12 +206,13 @@ describe("Skills extension over a real transport (#2234)", () => {
 
         const first = await connected.listSkills();
         // On the modern leg this call resolving is itself the envelope
-        // assertion: `listSkills` selects `ModernListSkillsResultSchema` from
-        // the negotiated era, and that schema rejects a page without
-        // `resultType` / `ttlMs` / `cacheScope`. It cannot be asserted on the
-        // returned value — `listSkills` narrows its result to the two fields
-        // below — so a modern page missing the envelope surfaces here as a
-        // rejection rather than as a missing property.
+        // assertion, in two halves: the SDK codec rejects a result without
+        // `resultType` (and lifts it off), and `listSkills` then selects
+        // `ModernListSkillsResultSchema`, which rejects a page without
+        // `ttlMs` / `cacheScope`. It cannot be asserted on the returned value
+        // — `listSkills` narrows its result to the two fields below — so a
+        // modern page missing the envelope surfaces here as a rejection
+        // rather than as a missing property.
         //
         // The fixture pages at two over eight skills, so a client that stops
         // here sees a quarter of the catalog.
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts
new file mode 100644
index 00000000..fd4649b6
--- /dev/null
+++ b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts
@@ -0,0 +1,366 @@
+import { describe, it, expect, afterEach } from "vitest";
+import {
+  createServer,
+  type IncomingMessage,
+  type Server,
+  type ServerResponse,
+} from "node:http";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { FetchRequestLogState } from "@inspector/core/mcp/state/index.js";
+import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
+import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
+import type {
+  AnnotatedRequestTimeoutData,
+  ConnectionDiagnostics,
+} from "@inspector/core/mcp/connectionDiagnostics.js";
+
+/**
+ * Request-timeout diagnostics against a real connection (#2318).
+ *
+ * The #2187 shape: a server that answers `initialize`, holds the standalone
+ * `GET /mcp` notification stream open without ever emitting an event, and
+ * never answers `tools/list`. The SDK's per-request timeout fires and the
+ * Inspector's word on it used to be `Request timed out`. These tests drive the
+ * real transport, the real SDK client and the real timeout, and assert on what
+ * the rejection now says — plus the snapshot and the events it is built from.
+ *
+ * Hand-rolled rather than a composable test server because the fixture has
+ * to misbehave in ways the SDK's own server cannot be configured to: hold a
+ * request forever, and push raw bytes down the GET stream on cue.
+ */
+
+/** A short budget so the timeout fires in test time rather than in 60s. */
+const REQUEST_TIMEOUT_MS = 300;
+
+type ToolsListMode = "hang" | "error";
+
+interface HangingServer {
+  url: string;
+  /** Every JSON-RPC method the server was sent, in order. */
+  calls: string[];
+  /** Write one SSE event onto every open GET stream. */
+  pushEvent: (message: Record) => void;
+  /** Write a comment-only keepalive block onto every open GET stream. */
+  pushKeepalive: () => void;
+  /** How many GET streams have been opened so far. */
+  streamsOpened: () => number;
+  setToolsListMode: (mode: ToolsListMode) => void;
+  stop: () => Promise;
+}
+
+function startHangingServer(): Promise {
+  const calls: string[] = [];
+  const streams = new Set();
+  const held = new Set();
+  let streamsOpened = 0;
+  let toolsListMode: ToolsListMode = "hang";
+
+  const handler = async (req: IncomingMessage, res: ServerResponse) => {
+    if (req.method === "GET") {
+      streamsOpened += 1;
+      streams.add(res);
+      res.on("close", () => streams.delete(res));
+      res.writeHead(200, {
+        "content-type": "text/event-stream",
+        "cache-control": "no-cache",
+      });
+      // Headers out now, so the client's fetch resolves and the stream reads
+      // as open — without a byte of event data on it.
+      res.flushHeaders();
+      return;
+    }
+    if (req.method === "DELETE") {
+      res.writeHead(200).end();
+      return;
+    }
+    const chunks: Buffer[] = [];
+    for await (const chunk of req) chunks.push(chunk as Buffer);
+    const body = JSON.parse(Buffer.concat(chunks).toString()) as {
+      id?: unknown;
+      method: string;
+    };
+    calls.push(body.method);
+    // Notifications (`notifications/initialized`, and the SDK's own
+    // `notifications/cancelled` when the timeout fires) get an empty 202.
+    if (body.id === undefined) {
+      res.writeHead(202).end();
+      return;
+    }
+    const reply = (payload: Record) => {
+      res.writeHead(200, { "content-type": "application/json" });
+      res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, ...payload }));
+    };
+    if (body.method === "initialize") {
+      reply({
+        result: {
+          protocolVersion: "2025-06-18",
+          capabilities: { tools: {} },
+          serverInfo: { name: "hanging-server", version: "1.0.0" },
+        },
+      });
+      return;
+    }
+    if (body.method === "tools/list") {
+      if (toolsListMode === "error") {
+        reply({ error: { code: -32603, message: "tools/list exploded" } });
+        return;
+      }
+      // Never answered. Kept so teardown can release the socket.
+      held.add(res);
+      res.on("close", () => held.delete(res));
+      return;
+    }
+    reply({ result: {} });
+  };
+
+  const server: Server = createServer((req, res) => {
+    void handler(req, res);
+  });
+
+  return new Promise((resolve) => {
+    server.listen(0, "127.0.0.1", () => {
+      const address = server.address();
+      /* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */
+      const port = typeof address === "object" && address ? address.port : 0;
+      resolve({
+        url: `http://127.0.0.1:${port}/mcp`,
+        calls,
+        pushEvent: (message) => {
+          for (const res of streams) {
+            res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
+          }
+        },
+        pushKeepalive: () => {
+          for (const res of streams) res.write(": keepalive\n\n");
+        },
+        streamsOpened: () => streamsOpened,
+        setToolsListMode: (mode) => {
+          toolsListMode = mode;
+        },
+        stop: () =>
+          new Promise((done) => {
+            for (const res of held) res.destroy();
+            for (const res of streams) res.destroy();
+            server.closeAllConnections();
+            server.close(() => done());
+          }),
+      });
+    });
+  });
+}
+
+/** Poll until `predicate` holds; the failure names what never became true. */
+async function waitUntil(
+  what: string,
+  predicate: () => boolean,
+  timeoutMs = 5000,
+): Promise {
+  const deadline = Date.now() + timeoutMs;
+  while (!predicate()) {
+    if (Date.now() > deadline) {
+      throw new Error(`timed out waiting for ${what}`);
+    }
+    await new Promise((r) => setTimeout(r, 10));
+  }
+}
+
+describe("InspectorClient request-timeout diagnostics (#2318)", () => {
+  let client: InspectorClient | null = null;
+  let server: HangingServer | null = null;
+
+  async function connectTo(url: string): Promise {
+    client = new InspectorClient(
+      { type: "streamable-http", url },
+      {
+        environment: { transport: createTransportNode },
+        // Pinned legacy so the connect is exactly `initialize` and nothing
+        // else — the assertions below name the last response by method.
+        versionNegotiation: eraToVersionNegotiation("legacy"),
+        timeout: REQUEST_TIMEOUT_MS,
+      },
+    );
+    await client.connect();
+    return client;
+  }
+
+  /** The SDK opens the GET stream after `notifications/initialized`, off the connect path. */
+  async function waitForNotificationStream(c: InspectorClient): Promise {
+    await waitUntil(
+      "the notification stream to be tracked",
+      () => c.getConnectionDiagnostics().notificationStream !== undefined,
+    );
+  }
+
+  afterEach(async () => {
+    try {
+      await client?.disconnect();
+    } catch {
+      // Teardown only — the assertions already ran.
+    }
+    client = null;
+    await server?.stop();
+    server = null;
+  });
+
+  it("annotates the timeout with the request, the unanswered set, the last response and the stream", async () => {
+    server = await startHangingServer();
+    const c = await connectTo(server.url);
+    await waitForNotificationStream(c);
+    server.pushKeepalive();
+
+    let caught: unknown;
+    try {
+      await c.listTools();
+    } catch (err) {
+      caught = err;
+    }
+
+    expect(SdkError.isInstance(caught)).toBe(true);
+    const error = caught as SdkError;
+    expect(error.code).toBe(SdkErrorCode.RequestTimeout);
+    expect(error.message).toMatch(
+      new RegExp(
+        "^Request timed out after 300ms \\(tools/list\\)\\. " +
+          "1 request is unanswered: tools/list \\(sent \\d+(ms|s) ago\\)\\. " +
+          "Last response received \\d+(ms|s) ago \\(initialize\\)\\. " +
+          "Notification stream \\(GET /mcp\\) open for \\d+(ms|s), 0 events delivered\\.$",
+      ),
+    );
+    // The structured form rides along for consumers that want it.
+    const data = error.data as AnnotatedRequestTimeoutData;
+    expect(data.timeout).toBe(REQUEST_TIMEOUT_MS);
+    expect(data.method).toBe("tools/list");
+    expect(data.diagnostics.outstandingRequests.map((r) => r.method)).toEqual([
+      "tools/list",
+    ]);
+    expect(data.diagnostics.lastResponse?.method).toBe("initialize");
+    expect(data.diagnostics.notificationStream?.eventCount).toBe(0);
+    // The SDK gave up on the request; the connection did not answer it. It is
+    // still unanswered, and the live snapshot keeps saying so.
+    expect(
+      c.getConnectionDiagnostics().outstandingRequests.map((r) => r.method),
+    ).toEqual(["tools/list"]);
+    // The keepalive comment reached the client and was not counted.
+    expect(c.getConnectionDiagnostics().notificationStream?.eventCount).toBe(0);
+    // The SDK's legacy cancel rode the wire and the server saw the whole
+    // exchange in order. The cancel is fire-and-forget on the SDK side — the
+    // rejection lands before the POST does — so wait for it to arrive.
+    const calls = server.calls;
+    await waitUntil("the cancellation to reach the server", () =>
+      calls.includes("notifications/cancelled"),
+    );
+    expect(calls).toEqual([
+      "initialize",
+      "notifications/initialized",
+      "tools/list",
+      "notifications/cancelled",
+    ]);
+  });
+
+  it("emits connectionDiagnosticsChange as the state moves, and counts stream events", async () => {
+    server = await startHangingServer();
+    const c = await connectTo(server.url);
+    const log = new FetchRequestLogState(c);
+    const snapshots: ConnectionDiagnostics[] = [];
+    c.addEventListener("connectionDiagnosticsChange", (event) =>
+      snapshots.push(event.detail),
+    );
+    try {
+      await waitForNotificationStream(c);
+      const stream = c.getConnectionDiagnostics().notificationStream!;
+      expect(stream.url).toBe(server.url);
+      expect(stream.closedAt).toBeUndefined();
+      // Open from when the headers arrived, not from when the GET went out.
+      const getEntryAtOpen = log
+        .getFetchRequests()
+        .find((entry) => entry.method === "GET")!;
+      expect(stream.openedAt).toBe(
+        getEntryAtOpen.timestamp.getTime() + (getEntryAtOpen.duration ?? 0),
+      );
+      // The snapshot is a copy: mutating it does not reach the client.
+      const snapshot = c.getConnectionDiagnostics();
+      snapshot.outstandingRequests.push({
+        id: "x",
+        method: "bogus",
+        sentAt: 0,
+      });
+      expect(c.getConnectionDiagnostics().outstandingRequests).toEqual([]);
+
+      // A server push on the notification stream is an event.
+      server.pushEvent({
+        jsonrpc: "2.0",
+        method: "notifications/tools/list_changed",
+      });
+      await waitUntil(
+        "the stream event to be counted",
+        () => c.getConnectionDiagnostics().notificationStream?.eventCount === 1,
+      );
+      // ...and the Network log's entry for the GET carries the same state.
+      const getEntry = log
+        .getFetchRequests()
+        .find((entry) => entry.method === "GET");
+      expect(getEntry?.stream).toEqual({ eventCount: 1 });
+
+      // The timeout path dispatched a snapshot with the request outstanding.
+      await expect(c.listTools()).rejects.toThrow(/Request timed out/);
+      expect(
+        snapshots.some((s) =>
+          s.outstandingRequests.some((r) => r.method === "tools/list"),
+        ),
+      ).toBe(true);
+
+      // Disconnecting aborts the stream: the watcher reports the close and
+      // the snapshot — which outlives the session until the next connect —
+      // records it.
+      await c.disconnect();
+      await waitUntil(
+        "the stream close to be recorded",
+        () =>
+          c.getConnectionDiagnostics().notificationStream?.closedAt !==
+          undefined,
+      );
+      expect(c.getConnectionDiagnostics().notificationStream).toMatchObject({
+        eventCount: 1,
+      });
+    } finally {
+      log.destroy();
+    }
+  });
+
+  it("starts the next session clean", async () => {
+    server = await startHangingServer();
+    const c = await connectTo(server.url);
+    await waitForNotificationStream(c);
+    await expect(c.listTools()).rejects.toThrow(/Request timed out/);
+    expect(c.getConnectionDiagnostics().outstandingRequests).toHaveLength(1);
+
+    await c.disconnect();
+    await c.connect();
+
+    const fresh = c.getConnectionDiagnostics();
+    expect(fresh.outstandingRequests).toEqual([]);
+    expect(fresh.lastResponse?.method).toBe("initialize");
+    // Whether the new session's GET has landed yet is timing; what must not be
+    // there is the previous session's stream.
+    await waitForNotificationStream(c);
+    expect(c.getConnectionDiagnostics().notificationStream?.closedAt).toBe(
+      undefined,
+    );
+    expect(server.streamsOpened()).toBe(2);
+  });
+
+  it("leaves a failure that is not a timeout exactly as the SDK threw it", async () => {
+    server = await startHangingServer();
+    server.setToolsListMode("error");
+    const c = await connectTo(server.url);
+
+    await expect(c.listTools()).rejects.toThrow(/tools\/list exploded/);
+    await expect(c.listTools()).rejects.not.toThrow(/unanswered/);
+    // Answered (with an error), so nothing is outstanding.
+    expect(c.getConnectionDiagnostics().outstandingRequests).toEqual([]);
+    expect(c.getConnectionDiagnostics().lastResponse?.method).toBe(
+      "tools/list",
+    );
+  });
+});
diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts
index 55dd7dca..0d89558d 100644
--- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts
+++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts
@@ -1,7 +1,12 @@
 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
 import { resolve } from "node:path";
+import type { Transport } from "@modelcontextprotocol/client";
 import * as z from "zod/v4";
-import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import {
+  InspectorClient,
+  connectionTimeoutMessage,
+} from "@inspector/core/mcp/inspectorClient.js";
+import { DEFAULT_CONNECTION_TIMEOUT_MS } from "@inspector/core/mcp/types.js";
 import {
   MessageLogState,
   FetchRequestLogState,
@@ -74,6 +79,7 @@ import type {
   ContentBlock,
 } from "@modelcontextprotocol/client";
 import {
+  Client,
   LOG_LEVEL_META_KEY,
   RELATED_TASK_META_KEY,
   SdkError,
@@ -362,25 +368,40 @@ describe("InspectorClient", () => {
       messageLogState.destroy();
     });
 
+    /**
+     * A transport whose `start()` never resolves: the "server that accepts
+     * the TCP connection and never answers" from the #2320 measurement.
+     * Typed as the SDK's `Transport` rather than cast to it, so an interface
+     * change upstream is caught here instead of at runtime.
+     */
+    function hangingTransport(): Transport {
+      return {
+        start: () => new Promise(() => {}),
+        send: async () => {},
+        close: async () => {},
+      };
+    }
+
+    /** A transport whose `start()` rejects with a recoverable 401. */
+    function unauthorizedTransport(): Transport {
+      return {
+        start: async () => {
+          const err = new Error("Unauthorized") as Error & { status?: number };
+          err.status = 401;
+          throw err;
+        },
+        send: async () => {},
+        close: async () => {},
+      };
+    }
+
     it("rejects connect() with a timeout error when serverSettings.connectionTimeout fires", async () => {
       // Stub transport whose start() never resolves — simulates a slow /
       // unreachable upstream. InspectorClient.connect() should race against
       // serverSettings.connectionTimeout and reject with a descriptive error;
       // status should end up in "error", and the client should have
       // internally torn down the transport (next connect() must rebuild).
-      const hangingTransport = {
-        start: () => new Promise(() => {}),
-        send: async () => {},
-        close: async () => {},
-        onclose: undefined,
-        onerror: undefined,
-        onmessage: undefined,
-        sessionId: undefined,
-      };
-      const fakeFactory = () => ({
-        transport:
-          hangingTransport as unknown as import("@modelcontextprotocol/client").Transport,
-      });
+      const fakeFactory = () => ({ transport: hangingTransport() });
       client = new InspectorClient(
         { type: "streamable-http", url: "http://localhost:1/never" },
         {
@@ -411,24 +432,106 @@ describe("InspectorClient", () => {
       expect(client.getStatus()).toBe("error");
     });
 
-    it("holds status at connecting when connect fails with a recoverable 401", async () => {
-      const unauthorizedTransport = {
-        start: async () => {
-          const err = new Error("Unauthorized") as Error & { status?: number };
-          err.status = 401;
-          throw err;
+    it("names the bound that fired and where to raise it in the timeout message (#2320)", async () => {
+      const fakeFactory = () => ({ transport: hangingTransport() });
+      client = new InspectorClient(
+        { type: "streamable-http", url: "http://localhost:1/never" },
+        {
+          environment: { transport: fakeFactory },
+          serverSettings: {
+            headers: [],
+            env: [],
+            metadata: {},
+            connectionTimeout: 50,
+            requestTimeout: 0,
+            taskTtl: 0,
+            maxFetchRequests: 1000,
+            roots: [],
+          },
         },
-        send: async () => {},
-        close: async () => {},
-        onclose: undefined,
-        onerror: undefined,
-        onmessage: undefined,
-        sessionId: undefined,
-      };
-      const fakeFactory = () => ({
-        transport:
-          unauthorizedTransport as unknown as import("@modelcontextprotocol/client").Transport,
-      });
+      );
+      await expect(client.connect()).rejects.toThrow(
+        connectionTimeoutMessage(50),
+      );
+      // The sentence a user reads on the toast: the bound, then the remedy.
+      expect(connectionTimeoutMessage(50)).toBe(
+        "Connection timed out after 50 ms. To accommodate a slower server, " +
+          "you may increase the timeout value in 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",
+      // and the only thing ending a hung handshake was the SDK's 60 s
+      // per-request timeout on `initialize`, reporting `Request timed out`.
+      // Fake timers so the 30 s default can fire without waiting it out.
+      vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+      try {
+        const fakeFactory = () => ({ transport: hangingTransport() });
+        client = new InspectorClient(
+          { type: "streamable-http", url: "http://localhost:1/never" },
+          { environment: { transport: fakeFactory } },
+        );
+        const pending = client.connect();
+        // Hold the rejection so it cannot surface as unhandled while the clock
+        // is advanced; the assertion below re-awaits the same promise.
+        pending.catch(() => {});
+        // One ms short of the default: still connecting, nothing has fired.
+        await vi.advanceTimersByTimeAsync(DEFAULT_CONNECTION_TIMEOUT_MS - 1);
+        expect(client.getStatus()).toBe("connecting");
+        await vi.advanceTimersByTimeAsync(1);
+        await expect(pending).rejects.toThrow(
+          connectionTimeoutMessage(DEFAULT_CONNECTION_TIMEOUT_MS),
+        );
+        expect(client.getStatus()).toBe("error");
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+
+    it("leaves connect() unbounded when connectionTimeout is an explicit 0 (#2320)", async () => {
+      // 0 is the documented opt-out (`--connect-timeout 0`, a cleared field),
+      // so it must not be read as "absent" and replaced with the default.
+      vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+      try {
+        let settled = false;
+        const fakeFactory = () => ({ transport: hangingTransport() });
+        client = new InspectorClient(
+          { type: "streamable-http", url: "http://localhost:1/never" },
+          {
+            environment: { transport: fakeFactory },
+            serverSettings: {
+              headers: [],
+              env: [],
+              metadata: {},
+              connectionTimeout: 0,
+              requestTimeout: 0,
+              taskTtl: 0,
+              maxFetchRequests: 1000,
+              roots: [],
+            },
+          },
+        );
+        const pending = client.connect().finally(() => {
+          settled = true;
+        });
+        pending.catch(() => {});
+        await vi.advanceTimersByTimeAsync(DEFAULT_CONNECTION_TIMEOUT_MS * 2);
+        expect(settled).toBe(false);
+        expect(client.getStatus()).toBe("connecting");
+        // Tear down by hand so the hung attempt does not outlive the test;
+        // disconnect() rejects the pending connect, which `pending.catch`
+        // above already absorbs.
+        await client.disconnect();
+        client = null;
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+
+    it("holds status at connecting when connect fails with a recoverable 401", async () => {
+      const fakeFactory = () => ({ transport: unauthorizedTransport() });
       client = new InspectorClient(
         { type: "streamable-http", url: "http://localhost:8081/mcp" },
         { environment: { transport: fakeFactory } },
@@ -437,6 +540,35 @@ describe("InspectorClient", () => {
       await expect(client.connect()).rejects.toMatchObject({ status: 401 });
       expect(client.getStatus()).toBe("connecting");
     });
+
+    it("holds status at connecting on a recoverable 401 even with a connect timeout armed (#2320)", async () => {
+      // The timeout teardown used to be gated on `connectionTimeout > 0`, so
+      // it ran for *every* failed connect once a timeout was set — including
+      // this 401, whose status must stay at "connecting" for the auth
+      // recovery. With a non-zero default that gate would have fired for
+      // every user, so the teardown is now keyed to the timer actually
+      // winning the race, and this is the case that proves it.
+      const fakeFactory = () => ({ transport: unauthorizedTransport() });
+      client = new InspectorClient(
+        { type: "streamable-http", url: "http://localhost:8081/mcp" },
+        {
+          environment: { transport: fakeFactory },
+          serverSettings: {
+            headers: [],
+            env: [],
+            metadata: {},
+            connectionTimeout: 5000,
+            requestTimeout: 0,
+            taskTtl: 0,
+            maxFetchRequests: 1000,
+            roots: [],
+          },
+        },
+      );
+
+      await expect(client.connect()).rejects.toMatchObject({ status: 401 });
+      expect(client.getStatus()).toBe("connecting");
+    });
   });
 
   describe("Message Tracking", () => {
@@ -5661,7 +5793,7 @@ describe("InspectorClient", () => {
     });
 
     describe("getAppRendererClient", () => {
-      it("returns null before connect, and a cached proxy after connect", async () => {
+      it("returns null before connect, and the SDK client itself after connect", async () => {
         server = createTestServerHttp({
           serverInfo: createTestServerInfo(),
           tools: [createEchoTool()],
@@ -5677,79 +5809,26 @@ describe("InspectorClient", () => {
         client = c;
         await c.connect();
 
-        const proxy1 = c.getAppRendererClient();
-        expect(proxy1).not.toBeNull();
-        // Second call returns the cached proxy
-        expect(c.getAppRendererClient()).toBe(proxy1);
-        expect(
-          typeof (proxy1 as unknown as { setNotificationHandler?: unknown })
-            .setNotificationHandler,
-        ).toBe("function");
-      });
-
-      it("translates the ext-apps v1 schema-first setNotificationHandler call to v2's method-string form", async () => {
-        // Regression: `@modelcontextprotocol/ext-apps` (SDK v1 peer) subscribes
-        // with `setNotificationHandler(NotificationSchema, handler)`. On SDK v2
-        // that throws "'[object Object]' is not a spec notification method",
-        // breaking App rendering at connect. The proxy must translate the
-        // schema (whose `.shape.method.value` is the method literal) to the
-        // method string so the handler still fires on the real notification.
-        server = createTestServerHttp({
-          serverInfo: createTestServerInfo(),
-          tools: [createAddToolTool()],
-          listChanged: { tools: true },
-        });
-        await server.start();
-        const c = new InspectorClient(
-          { type: "streamable-http", url: server.url },
-          { environment: { transport: createTransportNode } },
-        );
-        client = c;
-        await c.connect();
-
-        const proxy = c.getAppRendererClient() as unknown as {
-          setNotificationHandler: (
-            schema: unknown,
-            handler: () => void,
-          ) => void;
-        };
-        // A v1-style Zod notification schema, as ext-apps passes it (NOT a
-        // string): its `.shape.method.value` carries the method literal.
-        const v1StyleSchema = {
-          shape: { method: { value: "notifications/tools/list_changed" } },
-        };
-        let handlerFired = false;
+        const appClient = c.getAppRendererClient();
+        expect(appClient).not.toBeNull();
+        // The same instance on every call — no per-call wrapper. ext-apps 2.x
+        // peers on SDK v2 and its `AppBridge` registers list-changed forwarding
+        // with `setNotificationHandler("notifications/…", handler)`, so the
+        // v1-peer translation proxy is gone (#1745).
+        expect(c.getAppRendererClient()).toBe(appClient);
+        expect(appClient).toBeInstanceOf(Client);
+        // The string-first registration the bridge performs must land on it
+        // directly and fire on the real notification.
         expect(() =>
-          proxy.setNotificationHandler(v1StyleSchema, () => {
-            handlerFired = true;
-          }),
-        ).not.toThrow();
-
-        // The registration must land under the extracted method string: trigger
-        // a real tools/list_changed and confirm the schema-first handler runs.
-        const addToolTool = await getTool(c, "add_tool");
-        await c.callTool(addToolTool, {
-          name: "added_via_schema_first",
-          description: "added at runtime",
-        });
-        await vi.waitFor(() => expect(handlerFired).toBe(true), {
-          timeout: 5000,
-        });
-
-        // A native string-first call (ours) passes through unchanged.
-        expect(() =>
-          proxy.setNotificationHandler(
+          appClient?.setNotificationHandler(
             "notifications/prompts/list_changed",
             () => {},
           ),
         ).not.toThrow();
 
-        // An unrecognized first arg (no `.shape.method.value`) can't be
-        // translated and falls through to the SDK, which rejects it clearly —
-        // we don't silently swallow a genuinely-malformed registration.
-        expect(() =>
-          proxy.setNotificationHandler({} as unknown, () => {}),
-        ).toThrow(/not a spec notification method/);
+        // Disconnecting withdraws it again.
+        await c.disconnect();
+        expect(c.getAppRendererClient()).toBeNull();
       });
     });
 
diff --git a/clients/web/src/test/integration/mcp/node/transport.test.ts b/clients/web/src/test/integration/mcp/node/transport.test.ts
index c813fdf0..83b8e48a 100644
--- a/clients/web/src/test/integration/mcp/node/transport.test.ts
+++ b/clients/web/src/test/integration/mcp/node/transport.test.ts
@@ -188,6 +188,35 @@ describe("Transport", () => {
       }
     });
 
+    it("tracks fetches when only onFetchResponseBody is supplied (#2318)", async () => {
+      // Any one of the tracking callbacks earns the tracker — a caller that
+      // wants only bodies (or only stream updates) must not be handed the
+      // bare fetch and silently receive nothing.
+      const server = createTestServerHttp({
+        serverInfo: createTestServerInfo(),
+        tools: [createEchoTool()],
+        serverType: "streamable-http",
+      });
+      try {
+        await server.start();
+        const bodies: string[] = [];
+        const result = createTransportNode(
+          { type: "streamable-http", url: server.url },
+          { onFetchResponseBody: (_id, body) => bodies.push(body) },
+        );
+        const client = new Client(
+          { name: "test-client", version: "1.0.0" },
+          { capabilities: {} },
+        );
+        await client.connect(result.transport);
+        await client.listTools();
+        await client.close();
+        expect(bodies.some((body) => body.includes("tools"))).toBe(true);
+      } finally {
+        await server.stop();
+      }
+    });
+
     it("should call onFetchRequest callback for streamable-http transport", async () => {
       const server = createTestServerHttp({
         serverInfo: createTestServerInfo(),
@@ -242,6 +271,90 @@ describe("Transport", () => {
       }
     });
 
+    it("records an intercepted 401's status, WWW-Authenticate and body (#2297)", async () => {
+      const challengeBody = JSON.stringify({
+        error: "invalid_token",
+        error_description: "token expired",
+      });
+      const wwwAuthenticate =
+        'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read"';
+      const fetchFn: typeof fetch = async () =>
+        new Response(challengeBody, {
+          status: 401,
+          statusText: "Unauthorized",
+          headers: {
+            "content-type": "application/json",
+            "www-authenticate": wwwAuthenticate,
+          },
+        });
+
+      const fetchRequests: FetchRequestEntryBase[] = [];
+      const onFetchResponseBody = vi.fn();
+      const result = createTransportNode(
+        { type: "streamable-http", url: "https://mcp.example/mcp" },
+        {
+          fetchFn,
+          interceptAuthChallenges: true,
+          onFetchRequest: (entry) => fetchRequests.push(entry),
+          onFetchResponseBody,
+        },
+      );
+
+      const client = new Client(
+        { name: "test-client", version: "1.0.0" },
+        { capabilities: {} },
+      );
+      await expect(client.connect(result.transport)).rejects.toThrow(
+        "MCP auth challenge (401)",
+      );
+
+      // The body is on the entry by the time the challenge is thrown — not a
+      // later update — so a caller that stops listening at the throw has it.
+      expect(fetchRequests).toHaveLength(1);
+      const [entry] = fetchRequests;
+      expect(entry.error).toBeUndefined();
+      expect(entry.responseStatus).toBe(401);
+      expect(entry.responseHeaders?.["www-authenticate"]).toBe(wwwAuthenticate);
+      expect(entry.responseBody).toBe(challengeBody);
+      expect(onFetchResponseBody).not.toHaveBeenCalled();
+    });
+
+    it("still throws an intercepted 401 whose body never ends (#2297)", async () => {
+      const fetchFn: typeof fetch = async () =>
+        new Response(
+          new ReadableStream({
+            start(controller) {
+              controller.enqueue(new TextEncoder().encode("partial"));
+            },
+          }),
+          {
+            status: 401,
+            headers: { "www-authenticate": 'Bearer error="invalid_token"' },
+          },
+        );
+
+      const fetchRequests: FetchRequestEntryBase[] = [];
+      const result = createTransportNode(
+        { type: "streamable-http", url: "https://mcp.example/mcp" },
+        {
+          fetchFn,
+          interceptAuthChallenges: true,
+          onFetchRequest: (entry) => fetchRequests.push(entry),
+        },
+      );
+
+      const client = new Client(
+        { name: "test-client", version: "1.0.0" },
+        { capabilities: {} },
+      );
+      await expect(client.connect(result.transport)).rejects.toThrow(
+        "MCP auth challenge (401)",
+      );
+      expect(fetchRequests[0]?.responseStatus).toBe(401);
+      // Cut off at the tracker's read deadline rather than buffered forever.
+      expect(fetchRequests[0]?.responseBody).toBe("partial\n[truncated]");
+    });
+
     it("applies settings.headers to the outgoing streamable-http request", async () => {
       const server = createTestServerHttp({
         serverInfo: createTestServerInfo(),
diff --git a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts
new file mode 100644
index 00000000..96f30192
--- /dev/null
+++ b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts
@@ -0,0 +1,216 @@
+import { describe, it, expect, afterEach } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+  createTestServerHttp,
+  type TestServerHttp,
+  createTestServerInfo,
+  loadConfig,
+  resolveConfig,
+} from "@modelcontextprotocol/inspector-test-server";
+import {
+  getCimdClientMetadataUrlError,
+  parseClientConfig,
+} from "@inspector/core/client/config-parse.js";
+
+/**
+ * Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture
+ * that makes #2242 reproducible by hand.
+ *
+ * #2242 (CIMD provenance surviving the SDK's issuer-binding write) shipped
+ * verified by its own end-to-end tests, because nothing in this repo served a
+ * **client metadata document**: in CIMD the `client_id` is a URL the
+ * authorization server dereferences, so exercising it meant standing up a
+ * second host. The v2.6.0 release ledger recorded that as the one row with an
+ * observable UI surface and no way to reach it.
+ *
+ * What this file protects is the fixture itself, not the fix. The manual
+ * reproduction depends on three things being true of the served document, and
+ * each of them is the kind of thing that breaks silently:
+ *
+ *  - the AS advertises `client_id_metadata_document_supported`, or the
+ *    Inspector's CIMD pre-registration bails out before storing anything;
+ *  - it advertises **no** `registration_endpoint`, or a CIMD failure quietly
+ *    falls back to DCR and the repro passes while proving nothing (this is
+ *    exactly how the fixture first fooled its own author);
+ *  - the document's `client_id` equals the URL it was fetched from, which is
+ *    what makes it a legal CIMD client id rather than an arbitrary blob.
+ *
+ * The document is served over plain HTTP here, on `localhost`. Since #2305 that
+ * *is* a usable `clientMetadataUrl`: the Inspector's HTTPS requirement now
+ * exempts the same three loopback literals the SDK exempts for token endpoints,
+ * so the fixture can be driven from the web client with no self-signed HTTPS
+ * listener. The last case below pins that, because it is the property the
+ * fixture's whole reason for existing rests on — a re-tightened validator would
+ * put the manual repro back out of reach without failing anything else here.
+ */
+describe("CIMD showcase fixture (#2242)", () => {
+  let server: TestServerHttp | null = null;
+
+  const configPath = path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    "../../../../../../test-servers/configs/oauth-cimd-http.json",
+  );
+
+  afterEach(async () => {
+    if (server) {
+      try {
+        await server.stop();
+      } catch {
+        // ignore
+      }
+      server = null;
+    }
+  });
+
+  /**
+   * Boot the showcase config on a harness-chosen port, so this cannot collide
+   * with a showcase server someone is running by hand.
+   */
+  async function startShowcase(): Promise {
+    const resolved = resolveConfig(loadConfig(configPath));
+    const started = createTestServerHttp({
+      ...resolved,
+      serverInfo: createTestServerInfo("oauth-cimd-test", "1.0.0"),
+      port: undefined,
+    });
+    await started.start();
+    server = started;
+    return started;
+  }
+
+  /** The MCP endpoint's origin, which is also the AS and the document host. */
+  function originOf(started: TestServerHttp): string {
+    return new URL(started.url).origin;
+  }
+
+  it("resolves the config with CIMD on and DCR deliberately off", () => {
+    const resolved = resolveConfig(loadConfig(configPath));
+    expect(resolved.oauth?.supportCIMD).toBe(true);
+    // Not an oversight: with DCR available a CIMD failure silently succeeds
+    // via dynamic registration, and the repro stops proving anything.
+    expect(resolved.oauth?.supportDCR).toBe(false);
+    expect(resolved.oauth?.clientMetadata?.redirectUris).toContain(
+      "http://127.0.0.1:6276/oauth/callback",
+    );
+  });
+
+  it("advertises CIMD support and no registration endpoint", async () => {
+    const started = await startShowcase();
+    const res = await fetch(
+      `${originOf(started)}/.well-known/oauth-authorization-server`,
+    );
+    expect(res.ok).toBe(true);
+    const metadata = await res.json();
+
+    expect(metadata.client_id_metadata_document_supported).toBe(true);
+    // The half that keeps the repro honest.
+    expect(metadata.registration_endpoint).toBeUndefined();
+  });
+
+  it("serves a client metadata document whose client_id is its own URL", async () => {
+    const started = await startShowcase();
+    const documentUrl = `${originOf(started)}/client-metadata.json`;
+
+    const res = await fetch(documentUrl);
+    expect(res.ok).toBe(true);
+    const doc = await res.json();
+
+    // A CIMD client id IS the document's URL. Deriving it from the request
+    // rather than from a configured issuer is what keeps this true when the
+    // harness picks the port, as it does here.
+    expect(doc.client_id).toBe(documentUrl);
+    expect(doc.redirect_uris).toContain("http://127.0.0.1:6276/oauth/callback");
+    // CIMD clients are public; the server's own CIMD branch issues no secret.
+    expect(doc.token_endpoint_auth_method).toBe("none");
+  });
+
+  it("serves the document at a URL the Inspector accepts as a clientMetadataUrl", async () => {
+    const started = await startShowcase();
+    const documentUrl = `${originOf(started)}/client-metadata.json`;
+
+    // The #2305 property: no self-signed HTTPS listener, no
+    // NODE_TLS_REJECT_UNAUTHORIZED=0 — the served URL is legal config as-is,
+    // both inline in the settings form and in `client.json` on disk.
+    expect(new URL(documentUrl).protocol).toBe("http:");
+    expect(getCimdClientMetadataUrlError(documentUrl)).toBeUndefined();
+    expect(
+      parseClientConfig({
+        cimd: { enabled: true, clientMetadataUrl: documentUrl },
+      }).cimd?.clientMetadataUrl,
+    ).toBe(documentUrl);
+  });
+
+  it("preserves a query-bearing document URL in the client_id it publishes", async () => {
+    const started = await startShowcase();
+    const documentUrl = `${originOf(started)}/client-metadata.json?profile=a`;
+
+    const res = await fetch(documentUrl);
+    expect(res.ok).toBe(true);
+    const doc = await res.json();
+
+    // CIMD turns on the document's `client_id` being the URL it was fetched
+    // from. Answering `?profile=a` with the bare route would publish a
+    // document that fails that equality for a client id the server just
+    // served (Copilot).
+    expect(doc.client_id).toBe(documentUrl);
+  });
+
+  it("rejects a clientMetadataPath that would publish a foreign client_id", () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cimd-config-"));
+    const badPath = path.join(dir, "bad-cimd.json");
+    const base = JSON.parse(fs.readFileSync(configPath, "utf8"));
+    fs.writeFileSync(
+      badPath,
+      JSON.stringify({
+        ...base,
+        oauth: { ...base.oauth, clientMetadataPath: "//other-host/doc" },
+      }),
+    );
+
+    try {
+      // The path is not merely advertised: it becomes the document's own
+      // `client_id`, so an off-origin value publishes a client id naming a
+      // host this server does not serve.
+      expect(() => loadConfig(badPath)).toThrow(/clientMetadataPath/);
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects the same path built programmatically rather than from JSON", async () => {
+    const resolved = resolveConfig(loadConfig(configPath));
+    const started = createTestServerHttp({
+      ...resolved,
+      oauth: { ...resolved.oauth!, clientMetadataPath: "/doc?version=1" },
+      serverInfo: createTestServerInfo("oauth-cimd-badpath-test", "1.0.0"),
+      port: undefined,
+    });
+    server = started;
+
+    // `loadConfig` covers the JSON route only, so the server-setup check is
+    // what catches a `ServerConfig` assembled in code — the same split the
+    // two existing metadata paths have.
+    await expect(started.start()).rejects.toThrow(/clientMetadataPath/);
+    server = null;
+  });
+
+  it("does not serve the document when CIMD is switched off", async () => {
+    const resolved = resolveConfig(loadConfig(configPath));
+    const started = createTestServerHttp({
+      ...resolved,
+      oauth: { ...resolved.oauth!, supportCIMD: false },
+      serverInfo: createTestServerInfo("oauth-cimd-off-test", "1.0.0"),
+      port: undefined,
+    });
+    await started.start();
+    server = started;
+
+    const res = await fetch(`${originOf(started)}/client-metadata.json`);
+    // Advertising a client this server would then refuse to honour is a worse
+    // fixture than serving nothing at all.
+    expect(res.status).toBe(404);
+  });
+});
diff --git a/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts b/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts
index d9965b68..1663085e 100644
--- a/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts
+++ b/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts
@@ -74,7 +74,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
     serverUrl = `http://localhost:${port}`;
     storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2071-"));
     await waitForOAuthWellKnown(serverUrl);
-  }, 30_000);
+  });
 
   afterAll(async () => {
     await mcpServer?.stop();
@@ -83,7 +83,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
       rmSync(storageDir, { recursive: true, force: true });
       storageDir = "";
     }
-  }, 30_000);
+  });
 
   it("advertises the non-default metadata URL on the 401 challenge", async () => {
     const response = await fetch(`${serverUrl}/mcp`, { method: "POST" });
@@ -179,7 +179,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
     ).toEqual([]);
 
     await client.disconnect();
-  }, 30_000);
+  });
 
   it("observes the SSE challenge through a caller-supplied eventSourceInit.fetch", async () => {
     // That explicit fetch is deliberately not proxy- or intercept-wrapped, so
@@ -225,7 +225,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
     expect(observed).toContainEqual(
       expect.objectContaining({ resourceMetadataUrl: METADATA_URL_LITERAL }),
     );
-  }, 30_000);
+  });
 
   it("keeps the showcase config valid and carrying the custom path", () => {
     const configPath = fileURLToPath(
diff --git a/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts b/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts
index 612b4cbf..bb440fbd 100644
--- a/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts
@@ -174,7 +174,7 @@ describe("web-client cancellation over a per-request stream (#2140)", () => {
     // `notifications/cancelled` it was free to ignore (and did), its request
     // signal never fired, and this promise never resolved.
     await expect(slow.aborted).resolves.toBe(true);
-  }, 30_000);
+  });
 
   it("advertises the per-request stream only for streamable-http", () => {
     // The flag is what the SDK forks on, and the factory is where a server's
diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts
index 355f5bb8..ef1a7967 100644
--- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts
@@ -193,6 +193,69 @@ describe("server.ts /api/mcp/* branch coverage", () => {
         await new Promise((r) => upstream.server.close(() => r()));
       }
     });
+
+    it("relays a streamable-http 401's status and body over the event stream (#2297)", async () => {
+      const upstream = await startUnauthorizedUpstream();
+      const events = new AbortController();
+      try {
+        const connected = (await (
+          await connect(h, { type: "streamable-http", url: upstream.url })
+        ).json()) as { sessionId?: string };
+        expect(connected.sessionId).toBeDefined();
+
+        const stream = await fetch(
+          `${h.baseUrl}/api/mcp/events?sessionId=${connected.sessionId}`,
+          { signal: events.signal },
+        );
+        const sent = await fetch(`${h.baseUrl}/api/mcp/send`, {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            sessionId: connected.sessionId,
+            message: {
+              jsonrpc: "2.0",
+              id: 1,
+              method: "initialize",
+              params: {
+                protocolVersion: "2025-06-18",
+                capabilities: {},
+                clientInfo: { name: "test", version: "1.0.0" },
+              },
+            },
+          }),
+        });
+        expect(await sent.json()).toMatchObject({
+          ok: false,
+          kind: "auth_challenge",
+        });
+
+        const reader = stream.body!.getReader();
+        const decoder = new TextDecoder();
+        const pattern = /data: (\{"type":"fetch_request",.*?\})\n/;
+        let text = "";
+        let match: RegExpMatchArray | null = null;
+        while (!(match = text.match(pattern))) {
+          const { done, value } = await reader.read();
+          if (done) break;
+          text += decoder.decode(value, { stream: true });
+        }
+        const event = JSON.parse(match![1]!) as {
+          data: {
+            responseStatus?: number;
+            responseBody?: string;
+            error?: string;
+          };
+        };
+        expect(event.data.error).toBeUndefined();
+        expect(event.data.responseStatus).toBe(401);
+        expect(event.data.responseBody).toBe(
+          JSON.stringify({ error: "unauthorized" }),
+        );
+      } finally {
+        events.abort();
+        await new Promise((r) => upstream.server.close(() => r()));
+      }
+    });
   });
 
   describe("POST /api/mcp/send", () => {
diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts
index c152b960..61d55a1d 100644
--- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts
@@ -7,11 +7,16 @@
  */
 
 import { describe, it, expect, vi } from "vitest";
-import type { Transport } from "@modelcontextprotocol/client";
+import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/client";
 import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
 import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
 import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
 
+// Stage a deliberately off-spec message as `unknown` and narrow with a single
+// cast — the guard branches these fixtures exercise are ones the typed
+// `JSONRPCMessage` shape forbids, and `unknown` is the safe top type to pass in.
+const asMsg = (m: unknown): JSONRPCMessage => m as JSONRPCMessage;
+
 function makeFetchEntry(
   overrides: Partial = {},
 ): FetchRequestEntryBase {
@@ -194,6 +199,30 @@ describe("RemoteSession", () => {
     });
   });
 
+  it("onFetchStreamUpdate forwards the event count, serializing closedAt to ISO when present (#2318)", () => {
+    const session = new RemoteSession("s10a");
+    const received: { type: string; data: unknown }[] = [];
+    session.setEventConsumer((event) =>
+      received.push({ type: event.type, data: event.data }),
+    );
+    session.onFetchStreamUpdate("req-1", { eventCount: 2 });
+    session.onFetchStreamUpdate("req-1", {
+      eventCount: 3,
+      closedAt: new Date("2026-01-01T00:00:05Z"),
+    });
+    expect(received).toEqual([
+      { type: "fetch_stream_update", data: { id: "req-1", eventCount: 2 } },
+      {
+        type: "fetch_stream_update",
+        data: {
+          id: "req-1",
+          eventCount: 3,
+          closedAt: "2026-01-01T00:00:05.000Z",
+        },
+      },
+    ]);
+  });
+
   it("onFetchRequest serializes a Date timestamp to an ISO string", () => {
     const session = new RemoteSession("s11");
     const received: { type: string; data: unknown }[] = [];
@@ -231,6 +260,76 @@ describe("RemoteSession", () => {
     await expect(wait).resolves.toBeUndefined();
   });
 
+  it("a matching progress notification re-arms the wait past its original deadline (#2028)", async () => {
+    vi.useFakeTimers();
+    const session = new RemoteSession("s-progress-reset");
+    const wait = session.waitForRequestResponse(5, 1000);
+    // Almost at the deadline, then a progress note for this request arrives.
+    await vi.advanceTimersByTimeAsync(900);
+    session.onMessage({
+      jsonrpc: "2.0",
+      method: "notifications/progress",
+      params: { progressToken: 5, progress: 1, total: 10 },
+    });
+    // Past the *original* 1000ms deadline: without the re-arm this would have
+    // already rejected, and the resolve below would be a no-op on a dead wait.
+    await vi.advanceTimersByTimeAsync(900);
+    session.onMessage({ jsonrpc: "2.0", id: 5, result: {} });
+    await expect(wait).resolves.toBeUndefined();
+    vi.useRealTimers();
+  });
+
+  it("a notifications/message does NOT re-arm the wait (log messages don't extend the deadline, #2028)", async () => {
+    vi.useFakeTimers();
+    const session = new RemoteSession("s-message-no-reset");
+    const wait = session.waitForRequestResponse(6, 1000);
+    const rejection = expect(wait).rejects.toThrow(/timed out/);
+    await vi.advanceTimersByTimeAsync(500);
+    session.onMessage({
+      jsonrpc: "2.0",
+      method: "notifications/message",
+      params: { level: "info", data: { msg: "tick" } },
+    });
+    await vi.advanceTimersByTimeAsync(500);
+    await rejection;
+    vi.useRealTimers();
+  });
+
+  it("noteRequestProgress is a no-op for an id with no pending wait", () => {
+    const session = new RemoteSession("s-progress-noop");
+    expect(() => session.noteRequestProgress(999)).not.toThrow();
+  });
+
+  it("a progress notification on a timeoutMs=0 wait leaves it timerless and it still resolves", async () => {
+    const session = new RemoteSession("s-progress-notimer");
+    const wait = session.waitForRequestResponse(7, 0);
+    session.onMessage({
+      jsonrpc: "2.0",
+      method: "notifications/progress",
+      params: { progressToken: 7, progress: 1 },
+    });
+    session.onMessage({ jsonrpc: "2.0", id: 7, result: {} });
+    await expect(wait).resolves.toBeUndefined();
+  });
+
+  it("a progress notification with a non-token progressToken re-arms nothing", async () => {
+    vi.useFakeTimers();
+    const session = new RemoteSession("s-progress-bad-token");
+    const wait = session.waitForRequestResponse(8, 1000);
+    const rejection = expect(wait).rejects.toThrow(/timed out/);
+    // A malformed token (object) must be ignored, not coerced to a key.
+    session.onMessage(
+      asMsg({
+        jsonrpc: "2.0",
+        method: "notifications/progress",
+        params: { progressToken: { bad: true }, progress: 1 },
+      }),
+    );
+    await vi.advanceTimersByTimeAsync(1000);
+    await rejection;
+    vi.useRealTimers();
+  });
+
   it("endSend is a no-op when no send is active", () => {
     const session = new RemoteSession("s-endsend-noop");
     expect(session.hasActiveSend()).toBe(false);
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 fddc10c9..51e93874 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,6 +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 {
   AuthChallengeError,
   AuthRecoveryRequiredError,
@@ -340,6 +341,148 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
       expect(onFetchResponseBody).toHaveBeenCalledWith("f1", "BODY");
     });
 
+    it("delivers fetch_stream_update events, rebuilding closedAt as a Date (#2318)", async () => {
+      const onFetchStreamUpdate = vi.fn();
+      const t = makeTransport(
+        {
+          events: () =>
+            sseResponse([
+              sseFrame({
+                type: "fetch_stream_update",
+                data: { id: "f1", eventCount: 2 },
+              }),
+              sseFrame({
+                type: "fetch_stream_update",
+                data: {
+                  id: "f1",
+                  eventCount: 3,
+                  closedAt: "2026-01-01T00:00:05.000Z",
+                },
+              }),
+            ]),
+        },
+        { onFetchStreamUpdate },
+      );
+      await t.start();
+      await tick();
+      expect(onFetchStreamUpdate).toHaveBeenCalledTimes(2);
+      expect(onFetchStreamUpdate).toHaveBeenNthCalledWith(1, "f1", {
+        eventCount: 2,
+      });
+      expect(onFetchStreamUpdate).toHaveBeenNthCalledWith(2, "f1", {
+        eventCount: 3,
+        closedAt: new Date("2026-01-01T00:00:05.000Z"),
+      });
+    });
+
+    it("reports a still-open stream closed on close(), since the backend's own close cannot arrive (#2318)", async () => {
+      const onFetchStreamUpdate = vi.fn();
+      const { response, push } = createPushableEventStream();
+      const t = makeTransport(
+        { events: () => response },
+        { onFetchStreamUpdate, onFetchRequest: vi.fn() },
+      );
+      await t.start();
+      const entry = (id: string, method: string, contentType: string) =>
+        ({
+          id,
+          method,
+          url: "http://x/mcp",
+          timestamp: new Date().toISOString(),
+          requestHeaders: {},
+          responseStatus: 200,
+          responseHeaders: { "Content-Type": contentType },
+        }) as never;
+      push({
+        type: "fetch_request",
+        data: entry("stream", "GET", "text/event-stream"),
+      });
+      push({
+        type: "fetch_request",
+        data: entry("post", "POST", "text/event-stream"),
+      });
+      push({
+        type: "fetch_stream_update",
+        data: { id: "stream", eventCount: 2 },
+      });
+      push({
+        type: "fetch_request",
+        data: entry("earlier", "GET", "text/event-stream"),
+      });
+      push({
+        type: "fetch_stream_update",
+        data: {
+          id: "earlier",
+          eventCount: 1,
+          closedAt: new Date().toISOString(),
+        },
+      });
+      await tick();
+      onFetchStreamUpdate.mockClear();
+      await t.close();
+      // Only the stream still open is reported closed, with its last count;
+      // the bounded POST and the already-closed stream are not.
+      expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1);
+      expect(onFetchStreamUpdate).toHaveBeenCalledWith("stream", {
+        eventCount: 2,
+        closedAt: expect.any(Date),
+      });
+      // A second close reports nothing more.
+      await t.close();
+      expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1);
+    });
+
+    it("synthesizes the close on close() for a consumer that registered only onFetchStreamUpdate", async () => {
+      const onFetchStreamUpdate = vi.fn();
+      const { response, push } = createPushableEventStream();
+      const t = makeTransport(
+        { events: () => response },
+        { onFetchStreamUpdate },
+      );
+      await t.start();
+      push({
+        type: "fetch_request",
+        data: {
+          id: "stream",
+          method: "GET",
+          url: "http://x/mcp",
+          timestamp: new Date().toISOString(),
+          requestHeaders: {},
+          responseStatus: 200,
+          responseHeaders: { "content-type": "text/event-stream" },
+        } as never,
+      });
+      push({
+        type: "fetch_stream_update",
+        data: { id: "stream", eventCount: 3 },
+      });
+      await tick();
+      await t.close();
+      expect(onFetchStreamUpdate).toHaveBeenLastCalledWith("stream", {
+        eventCount: 3,
+        closedAt: expect.any(Date),
+      });
+    });
+
+    it("drops fetch_stream_update events when no handler is registered", async () => {
+      const onFetchRequest = vi.fn();
+      const t = makeTransport(
+        {
+          events: () =>
+            sseResponse([
+              sseFrame({
+                type: "fetch_stream_update",
+                data: { id: "f1", eventCount: 1 },
+              }),
+            ]),
+        },
+        { onFetchRequest },
+      );
+      await t.start();
+      await tick();
+      expect(onFetchRequest).not.toHaveBeenCalled();
+    });
+
     it("delivers stdio_log events to onStderr", async () => {
       const onStderr = vi.fn();
       const t = makeTransport(
@@ -562,6 +705,27 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
       ).rejects.toThrow(/Transport is closed/);
     });
 
+    it("rejects an expired SSE response wait with the SDK's own timeout shape (#2318)", async () => {
+      // The relay's budget, not the request's, can be the one that expires;
+      // shaped as the SDK's timeout so InspectorClient annotates it the same.
+      const t = makeTransport(
+        {
+          events: () => openEventsResponse(),
+          send: () => jsonResponse({ ok: true }),
+        },
+        { sseResponseTimeoutMs: 50 },
+      );
+      await t.start();
+      await expect(
+        t.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
+      ).rejects.toMatchObject({
+        code: SdkErrorCode.RequestTimeout,
+        message: "Request timed out",
+        data: { timeout: 50 },
+      });
+      await t.close();
+    });
+
     it("does not wait for an SSE response on subscriptions/listen (long-lived stream)", async () => {
       // A `subscriptions/listen` never yields a JSON-RPC response — only an
       // acknowledged notification over the event channel — so send() must
diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts
index aabda5f2..4ebd734b 100644
--- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts
@@ -300,6 +300,12 @@ describe("server.ts supplemental coverage", () => {
     let h: Harness;
     let target: ServerType;
     let targetUrl: string;
+    /** Resolves when an upstream `/stall` request is closed by its client. */
+    let stallClosed: Promise;
+    /** Paths the upstream fixture has actually received. */
+    let upstreamHits: string[];
+    /** Resolves when the never-ending `/stream-open` response is closed. */
+    let openStreamClosed: Promise;
 
     // Isolate the WHOLE suite from the developer's (or CI's) proxy environment.
     // Setting only uppercase HTTP_PROXY in the one proxy test would not be
@@ -336,7 +342,43 @@ describe("server.ts supplemental coverage", () => {
       h = await start();
       // A tiny upstream HTTP server we can point /api/fetch at.
       const { createServer } = await import("node:http");
+      let markClosed: () => void = () => {};
+      stallClosed = new Promise((resolve) => {
+        markClosed = resolve;
+      });
+      upstreamHits = [];
+      let markStreamClosed: () => void = () => {};
+      openStreamClosed = new Promise((resolve) => {
+        markStreamClosed = resolve;
+      });
       const srv = createServer((req, res) => {
+        upstreamHits.push(req.url ?? "");
+        if (req.url === "/stream-hostile") {
+          // Headers, one frame, and a connection whose teardown the route must
+          // not wait on: `ReadableStream.cancel()` adopts the source's cancel
+          // promise, which may never settle.
+          res.writeHead(200, { "Content-Type": "text/event-stream" });
+          res.write("data: hi\n\n");
+          return;
+        }
+        if (req.url === "/stream-open") {
+          // Event-stream headers, one frame, and then nothing — the shape that
+          // used to leave a socket open for good, because the route classifies
+          // it as a stream, returns JSON without the body, and clears its
+          // deadline on the way out.
+          res.writeHead(200, { "Content-Type": "text/event-stream" });
+          res.write("data: hi\n\n");
+          res.on("close", () => markStreamClosed());
+          return;
+        }
+        if (req.url === "/stall") {
+          // Accept the request, answer nothing, and report when the client
+          // gives up — the #2319 wedged-authorization-server shape. `close` on
+          // the response fires whether the socket was torn down or the handler
+          // simply ended, and nothing here ever ends it.
+          res.on("close", () => markClosed());
+          return;
+        }
         if (req.url === "/stream") {
           res.writeHead(200, { "Content-Type": "text/event-stream" });
           res.write("data: hi\n\n");
@@ -381,6 +423,175 @@ describe("server.ts supplemental coverage", () => {
       await stop(h);
     });
 
+    it("releases the upstream request when the browser cancels (#2319)", async () => {
+      // The point of forwarding the signal through `createRemoteFetch`: without
+      // it the browser's abort settled only its own promise, and this handler
+      // plus the upstream socket stayed pending for as long as the server cared
+      // to stall — once per timed-out attempt. Asserted at the route rather
+      // than on a mock, because the mock cannot show the socket being released.
+      const caller = new AbortController();
+      const pending = fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ url: `${targetUrl}/stall` }),
+        signal: caller.signal,
+      });
+      const rejected = pending.catch((err: unknown) => err);
+
+      // Wait until the upstream has actually received the request, so the abort
+      // cannot race ahead of it and pass for the wrong reason.
+      await vi.waitFor(() => {
+        expect(upstreamHits).toContain("/stall");
+      });
+      caller.abort();
+
+      expect(await rejected).toBeInstanceOf(Error);
+      // The upstream sees its connection go away. Without the signal composed
+      // into the route's outbound fetch this never resolves and the test times
+      // out.
+      await stallClosed;
+    });
+
+    it("cancels a discarded stream that sends headers and never ends (#2319)", async () => {
+      // The route answers with JSON and no body, so nobody downstream owns the
+      // upstream stream and nothing will ever read it; the route cancels it
+      // explicitly rather than relying on that being reclaimed for it.
+      //
+      // ⚠️ This pins the OUTCOME, not the mechanism: measured, it still passes
+      // with the explicit `cancel()` removed, because undici reclaims an unread
+      // response body here on its own. So the cancel is defensive — it makes
+      // the release explicit and independent of that behaviour — and this test
+      // will catch the route starting to hold the stream open, not the cancel
+      // being deleted. Said plainly rather than left to imply a stronger claim.
+      const res = await fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ url: `${targetUrl}/stream-open` }),
+      });
+
+      expect(res.status).toBe(200);
+      const payload = (await res.json()) as { status: number; body?: string };
+      expect(payload.status).toBe(200);
+      expect(payload.body).toBeUndefined();
+      // The observable contract: the route does not leave the upstream stream
+      // running after it has answered. (Per the qualification above, this does
+      // not isolate the explicit `cancel()` as the cause.)
+      await openStreamClosed;
+    });
+
+    it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
+      // The caller's budget travels in the envelope, so this costs milliseconds
+      // rather than the production thirty seconds. What it pins is typed-marker
+      // preservation when the *backend* wins the client-side race — both ends
+      // run the same budget, and a backgrounded tab throttling `setTimeout` is
+      // the plausible way the server's fires first. The error has to survive
+      // the hop as something an `instanceof OAuthRequestTimeoutError` check can
+      // still recognize, whichever end produced it.
+      const res = await fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ url: `${targetUrl}/stall`, timeoutMs: 120 }),
+      });
+
+      expect(res.status).toBe(504);
+      const payload = (await res.json()) as {
+        error: string;
+        code: string;
+        url: string;
+        timeoutMs: number;
+      };
+      expect(payload.code).toBe("oauth_request_timeout");
+      expect(payload.url).toBe(`${targetUrl}/stall`);
+      expect(payload.timeoutMs).toBe(120);
+      expect(payload.error).toContain("timed out after 120ms");
+      // And the upstream is released rather than left running.
+      await stallClosed;
+    });
+
+    it("applies no deadline to a request that carries none (#2319)", async () => {
+      // ⚠️ The regression this guards: an unconditional timer here would abort
+      // a Streamable HTTP tool call that legitimately withholds its response
+      // headers — reintroducing on the backend exactly what `exemptMcpEndpoint`
+      // prevents on the client, and reporting it as an OAuth timeout besides.
+      //
+      // ⚠️ What it does NOT catch, stated so nobody reads more into it: the
+      // wait below is 400ms, so an unconditional timer restored at the
+      // production 30s budget would still leave the request pending here and
+      // the test would pass (Copilot). Catching that needs either a ~31s wait
+      // on every CI run or a route-level deadline knob existing only for the
+      // test — and a second source of truth for the deadline is the defect
+      // round 13 removed. Measured, this fails against an unconditional timer
+      // at any budget shorter than the wait.
+      const caller = new AbortController();
+      const pending = fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ url: `${targetUrl}/stall` }),
+        signal: caller.signal,
+      });
+      const settled = pending.then(
+        (r) => `responded ${r.status}`,
+        () => "aborted by us",
+      );
+
+      await vi.waitFor(() => {
+        expect(upstreamHits).toContain("/stall");
+      });
+      // Well past any budget the route might have applied on its own. Nothing
+      // should have answered: an unbounded request is still in flight.
+      await new Promise((r) => setTimeout(r, 400));
+      expect(
+        await Promise.race([settled, Promise.resolve("still pending")]),
+      ).toBe("still pending");
+
+      caller.abort();
+      expect(await settled).toBe("aborted by us");
+    });
+
+    it("ignores a non-numeric deadline rather than trusting it (#2319)", async () => {
+      const caller = new AbortController();
+      const pending = fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          url: `${targetUrl}/stall`,
+          timeoutMs: "soon",
+        }),
+        signal: caller.signal,
+      });
+      const settled = pending.then(
+        (r) => `responded ${r.status}`,
+        () => "aborted by us",
+      );
+
+      await vi.waitFor(() => {
+        expect(upstreamHits).toContain("/stall");
+      });
+      await new Promise((r) => setTimeout(r, 300));
+      expect(
+        await Promise.race([settled, Promise.resolve("still pending")]),
+      ).toBe("still pending");
+
+      caller.abort();
+      expect(await settled).toBe("aborted by us");
+    });
+
+    it("answers a discarded stream without waiting on its cancellation", async () => {
+      // ⚠️ The route starts the cancel and returns. Awaiting it would keep the
+      // handler pending on a source that never settles its cancel promise —
+      // and on an unbounded request there is no timer to release it either.
+      const res = await fetch(`${h.baseUrl}/api/fetch`, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ url: `${targetUrl}/stream-hostile` }),
+      });
+
+      expect(res.status).toBe(200);
+      const payload = (await res.json()) as { status: number; body?: string };
+      expect(payload.status).toBe(200);
+      expect(payload.body).toBeUndefined();
+    });
+
     it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
       // /api/fetch is the browser's ONLY way out to the network — the web
       // client's `environment.fetch` is `createRemoteFetch()`, which forwards
@@ -621,6 +832,13 @@ describe("server.ts supplemental coverage", () => {
       expect((await res.json()).error).toMatch(/maxFetchRequests/);
     });
 
+    it("rejects invalid skills catalog limits (#2294)", async () => {
+      const skills = await postSettings({ ...base, skillCatalogMaxSkills: 0 });
+      expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/);
+      const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 });
+      expect((await bytes.json()).error).toMatch(/skillCatalogMaxBytes/);
+    });
+
     it("rejects a non-string OAuth field", async () => {
       const res = await postSettings({ ...base, oauthClientId: 7 });
       expect((await res.json()).error).toMatch(/oauthClientId/);
@@ -749,6 +967,8 @@ describe("server.ts supplemental coverage", () => {
         paginatedLists: true,
         advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
         maxFetchRequests: 5,
+        skillCatalogMaxSkills: 10,
+        skillCatalogMaxBytes: 2048,
         protocolEra: "modern",
         modernLogLevel: "off",
         oauthClientId: "cid",
@@ -788,6 +1008,8 @@ describe("server.ts supplemental coverage", () => {
               requestTimeout: -1,
               taskTtl: "x",
               maxFetchRequests: -1,
+              skillCatalogMaxSkills: 0,
+              skillCatalogMaxBytes: "big",
               // unknown era literal → isProtocolEra branch
               protocolEra: "future",
               // unknown modern log level → isModernLogLevel branch (#1629)
@@ -819,6 +1041,8 @@ describe("server.ts supplemental coverage", () => {
           "requestTimeout",
           "taskTtl",
           "maxFetchRequests",
+          "skillCatalogMaxSkills",
+          "skillCatalogMaxBytes",
           "protocolEra",
           "modernLogLevel",
           "oauth",
diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts
index d0f9d407..a4fc1aa9 100644
--- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts
+++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts
@@ -430,7 +430,9 @@ describe("/api/servers routes", () => {
           settings: {
             headers: [{ key: "Authorization", value: "Bearer xyz" }],
             metadata: { tenant: "acme", limits: { rps: 10 } },
-            connectionTimeout: 30000,
+            // Non-default on purpose: 30000 is DEFAULT_CONNECTION_TIMEOUT_MS,
+            // which the write side omits from disk (#2320).
+            connectionTimeout: 45000,
             requestTimeout: 60000,
             oauthClientId: "client-abc",
             oauthScopes: "read:tools",
@@ -444,7 +446,7 @@ describe("/api/servers routes", () => {
       expect(stored).not.toHaveProperty("settings");
       expect(stored.headers).toEqual({ Authorization: "Bearer xyz" });
       expect(stored.metadata).toEqual({ tenant: "acme", limits: { rps: 10 } });
-      expect(stored.connectionTimeout).toBe(30000);
+      expect(stored.connectionTimeout).toBe(45000);
       expect(stored.requestTimeout).toBe(60000);
       expect(stored.oauth).toEqual({
         clientId: "client-abc",
@@ -596,9 +598,12 @@ describe("/api/servers routes", () => {
       expect(stored).not.toHaveProperty("settings");
       expect(stored.headers).toEqual({ "X-Tenant": "acme" });
       expect(stored.requestTimeout).toBe(45000);
-      // Zero/empty values are suppressed on disk to keep the diff minimal.
+      // Empty values are suppressed on disk to keep the diff minimal.
       expect(stored).not.toHaveProperty("metadata");
-      expect(stored).not.toHaveProperty("connectionTimeout");
+      // A connectionTimeout of 0 is the explicit "no timeout" opt-out, not the
+      // default (30 s) — so it must reach disk, or the next load would
+      // silently read it back as 30 s (#2320).
+      expect(stored.connectionTimeout).toBe(0);
     });
 
     it("persists autoRefreshOnListChanged: true through the PUT write path", async () => {
diff --git a/clients/web/src/test/integration/mcp/skills-strict-configs.test.ts b/clients/web/src/test/integration/mcp/skills-strict-configs.test.ts
new file mode 100644
index 00000000..8902371a
--- /dev/null
+++ b/clients/web/src/test/integration/mcp/skills-strict-configs.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, afterEach } from "vitest";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
+import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
+import {
+  createTestServerHttp,
+  type TestServerHttp,
+  loadConfig,
+  resolveConfig,
+} from "@modelcontextprotocol/inspector-test-server";
+
+/**
+ * Live coverage of the two checked-in strict Skills configs (#2373) — the
+ * documented manual reproduction of a server that enforces the client's half
+ * of SEP-2133 negotiation.
+ *
+ * `inspectorClient-skills.test.ts` exercises the fixture behavior by passing
+ * `skillsRequireClientExtension` straight to `createTestServerHttp`, so it
+ * never touches the JSON-to-`ServerConfig` mapping. A misspelled key in either
+ * file, or a `resolveConfig` that stopped threading the flag, would leave the
+ * manual fixtures quietly permissive while that suite stayed green. These
+ * tests **resolve the checked-in files** and assert the refusal they exist
+ * for, so that class fails here instead.
+ */
+const configsDir = path.resolve(
+  path.dirname(fileURLToPath(import.meta.url)),
+  "../../../../../../test-servers/configs",
+);
+
+const CONFIGS = [
+  { file: "skills-strict-legacy-http.json", era: "legacy" as const },
+  { file: "skills-strict-modern-http.json", era: "modern" as const },
+];
+
+describe("strict Skills showcase configs (#2373)", () => {
+  let client: InspectorClient | null = null;
+  let server: TestServerHttp | null = null;
+
+  afterEach(async () => {
+    if (client) {
+      try {
+        await client.disconnect();
+      } catch {
+        // ignore
+      }
+      client = null;
+    }
+    if (server) {
+      try {
+        await server.stop();
+      } catch {
+        // ignore
+      }
+      server = null;
+    }
+  });
+
+  for (const { file, era } of CONFIGS) {
+    describe(file, () => {
+      const configPath = path.join(configsDir, file);
+
+      it("resolves with the strict flag, skills, and its era intact", () => {
+        const resolved = resolveConfig(loadConfig(configPath));
+        expect(resolved.skills).toBe(true);
+        expect(resolved.skillsRequireClientExtension).toBe(true);
+        // One file per era is the documented contract: a legacy client
+        // reaching a modern server is served statelessly and cannot be
+        // checked, so the legacy file must not turn the modern leg on.
+        expect(resolved.modern !== undefined).toBe(era === "modern");
+      });
+
+      it("refuses skills/list to a client that did not declare the extension", async () => {
+        // The harness picks the port rather than the config's fixed one, so
+        // this cannot collide with a showcase server someone runs by hand.
+        const resolved = resolveConfig(loadConfig(configPath));
+        const started = createTestServerHttp({ ...resolved, port: undefined });
+        await started.start();
+        server = started;
+
+        const connected = new InspectorClient(
+          { type: "streamable-http", url: started.url },
+          {
+            environment: { transport: createTransportNode },
+            versionNegotiation: eraToVersionNegotiation(era),
+            advertisedExtensions: { "io.modelcontextprotocol/skills": false },
+          },
+        );
+        await connected.connect();
+        client = connected;
+        expect(connected.getProtocolEra()).toBe(era);
+
+        await expect(connected.listSkills()).rejects.toMatchObject({
+          code: era === "modern" ? -32021 : -32601,
+        });
+      });
+    });
+  }
+});
diff --git a/clients/web/src/test/integration/server/app-origin-controller.test.ts b/clients/web/src/test/integration/server/app-origin-controller.test.ts
index 92554db3..8be38899 100644
--- a/clients/web/src/test/integration/server/app-origin-controller.test.ts
+++ b/clients/web/src/test/integration/server/app-origin-controller.test.ts
@@ -23,6 +23,18 @@ import {
 } from "../../../../server/app-origin-controller.js";
 import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
 
+/** A port that was free a moment ago, for tests that need a FIXED port. */
+async function freePort(): Promise {
+  const probe: Server = createServer();
+  await new Promise((resolve) =>
+    probe.listen(0, "127.0.0.1", () => resolve()),
+  );
+  const addr = probe.address();
+  const port = typeof addr === "object" && addr !== null ? addr.port : 0;
+  await new Promise((resolve) => probe.close(() => resolve()));
+  return port;
+}
+
 describe("resolveAppOriginPort", () => {
   const saved = process.env.MCP_APP_ORIGIN_PORT;
   afterEach(() => {
@@ -178,6 +190,73 @@ describe("createAppOriginController", () => {
     await expect(res.text()).resolves.toBe("

app

"); }); + it("publishes under a public origin while still serving the bound listener (#1862)", async () => { + const publicOrigin = "https://apps.example.com"; + // A fixed port: a public origin is only used where a proxy can route. + const port = await freePort(); + controller = createAppOriginController({ + port, + host: "127.0.0.1", + publicOrigin, + }); + const first = await controller.start(); + expect(first.port).toBe(port); + expect(first.url).toBe(publicOrigin); + expect(controller.getOrigin()).toBe(publicOrigin); + // The cached start reports the BOUND port; parsing one out of a public + // origin with no explicit port would yield NaN. + expect(await controller.start()).toEqual(first); + + const published = controller.publish({ html: "

proxied

" })!; + expect(published.url.startsWith(`${publicOrigin}/app-document/`)).toBe( + true, + ); + // The reverse proxy's job is routing the public path to this listener; + // simulate it by fetching the same path on the bound port. + const path = new URL(published.url).pathname; + const res = await fetch(`http://127.0.0.1:${first.port}${path}`); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("

proxied

"); + }); + + it("does not use a public origin on an OS-assigned port (port 0)", async () => { + // Both an explicit MCP_APP_ORIGIN_PORT=0 and a config-time collision with + // CLIENT_PORT / MCP_SANDBOX_PORT (which buildWebServerConfig resolves to 0) + // land here: there is no stable port for a reverse proxy to route to. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + controller = createAppOriginController({ + port: 0, + host: "127.0.0.1", + publicOrigin: "https://apps.example.com", + }); + const { url, port } = await controller.start(); + expect(url).toBe(`http://127.0.0.1:${port}`); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it("does not adopt a public origin when the listener never bound", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + controller = createAppOriginController({ + port: 0, + host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface + publicOrigin: "https://apps.example.com", + }); + expect(await controller.start()).toEqual({ port: 0, url: "" }); + expect(controller.getOrigin()).toBeNull(); + // So the renderer takes its srcdoc fallback instead of a dead URL. + expect(controller.publish({ html: "

x

" })).toBeNull(); + } finally { + errorSpy.mockRestore(); + } + }); + it("serves only frame-ancestors when the document carries no csp", async () => { controller = createAppOriginController({ port: 0, host: "127.0.0.1" }); await controller.start(); @@ -350,6 +429,54 @@ describe("createAppOriginController", () => { } }); + it("does not use a public origin after falling back off its pinned port (#1862)", async () => { + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + controller = createAppOriginController({ + port, + host: "127.0.0.1", + publicOrigin: "https://apps.example.com", + }); + const result = await controller.start(); + expect(result.port).not.toBe(port); + expect(result.url).toBe(`http://127.0.0.1:${result.port}`); + expect( + controller.publish({ html: "

x

" })!.url.startsWith(result.url), + ).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"), + ); + } finally { + warnSpy.mockRestore(); + await release(); + } + }); + + it("refuses a public origin equal to a trusted ancestor, including a bind-derived sandbox (#1862)", async () => { + // Config time cannot see the bind-derived sandbox origin; the callers pass + // it in embedderOrigins, so the controller is where the collision is caught. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + controller = createAppOriginController({ + port: await freePort(), + host: "127.0.0.1", + publicOrigin: "http://127.0.0.1:6275", + embedderOrigins: appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [ + "http://localhost:6274", + ]), + }); + const result = await controller.start(); + expect(result.url).toBe(`http://127.0.0.1:${result.port}`); + expect(controller.getOrigin()).not.toBe("http://127.0.0.1:6275"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("must differ from both"), + ); + } finally { + warnSpy.mockRestore(); + } + }); + it("degrades to publish()===null when listen fails for a non-EADDRINUSE reason", async () => { // The backends await start() during boot; if it ever stopped resolving the // whole web server would hang. Instead it resolves empty and every publish diff --git a/clients/web/src/test/integration/server/public-address.test.ts b/clients/web/src/test/integration/server/public-address.test.ts new file mode 100644 index 00000000..95f49087 --- /dev/null +++ b/clients/web/src/test/integration/server/public-address.test.ts @@ -0,0 +1,196 @@ +/** + * Public addresses for the MCP Apps listeners (#1862): `MCP_SANDBOX_FULL_ADDRESS` + * and `MCP_APP_ORIGIN_FULL_ADDRESS`, as parsed by `server/public-address.ts`. + * + * Pinned here: what each accepts and how it is normalized (`/sandbox` appended + * to a bare origin, the app origin kept origin-only), every refusal reason, the + * same-origin refusals that keep the sandbox and a dedicated app origin off the + * Inspector's own origin, the mixed-content warning, and that a refused value + * never leaks a secret (credentials, query, fragment, path) into the log. The + * listener-side rules — fixed port only, no trusted-ancestor collision — are in + * the sandbox and app-origin controller suites, where the bound port is known. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + resolveAppOriginPublicOrigin, + resolveSandboxPublicUrl, +} from "../../../../server/public-address.js"; + +const UI = ["http://localhost:6274", "http://127.0.0.1:6274"]; + +describe("public addresses (#1862)", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + /** The single warning line, or undefined when nothing was warned. */ + function warned(): string | undefined { + return ( + warnSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n") || + undefined + ); + } + + describe("resolveSandboxPublicUrl", () => { + it.each([[undefined], [""], [" "]])( + "is unset, silently, for %j", + (raw) => { + expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined(); + expect(warned()).toBeUndefined(); + }, + ); + + it("advertises a full URL as given", () => { + expect( + resolveSandboxPublicUrl( + "https://inspector-sandbox.example.com/sandbox", + ["https://inspector.example.com"], + ), + ).toBe("https://inspector-sandbox.example.com/sandbox"); + expect(warned()).toBeUndefined(); + }); + + it("appends /sandbox to a bare origin, the only path the listener serves", () => { + expect( + resolveSandboxPublicUrl(" https://sb.example.com ", [ + "https://inspector.example.com", + ]), + ).toBe("https://sb.example.com/sandbox"); + }); + + it("keeps a non-root path, which a reverse proxy may rewrite", () => { + expect( + resolveSandboxPublicUrl("https://sb.example.com/mcp/sandbox", UI), + ).toBe("https://sb.example.com/mcp/sandbox"); + }); + + it.each([ + ["not a url", "not an absolute URL"], + ["ftp://sb.example.com/sandbox", "http:// or https://"], + ["https://user:pw@sb.example.com/sandbox", "credentials"], + ["https://*.example.com/sandbox", "wildcard"], + ["http://[::1]:6275/sandbox", "IPv6"], + ["https://sb.example.com/sandbox?x=1", "query string or fragment"], + ["https://sb.example.com/sandbox#frag", "query string or fragment"], + ])("refuses %s", (raw, reason) => { + expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined(); + expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS"); + expect(warned()).toContain(reason); + }); + + it.each([ + ["https://user:hunter2@sb.example.com/sandbox", "hunter2"], + ["https://sb.example.com/sandbox?token=hunter2", "hunter2"], + ["https://sb.example.com/sandbox#hunter2", "hunter2"], + ["not a url hunter2", "hunter2"], + ])("never echoes a secret from a refused value (%s)", (raw, secret) => { + expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined(); + expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS"); + expect(warned()).not.toContain(secret); + }); + + it("never echoes a path, which can carry a secret too", () => { + expect( + resolveAppOriginPublicOrigin( + "https://apps.example.com/hunter2", + ["https://inspector.example.com"], + undefined, + ), + ).toBeUndefined(); + expect(warned()).toContain('"https://apps.example.com"'); + expect(warned()).not.toContain("hunter2"); + }); + + it("refuses an origin shared with the Inspector UI — the same-origin collapse", () => { + // The natural reverse-proxy layout: one hostname, /sandbox routed to 6275. + // The spec requires host != sandbox origin, so this must not be advertised. + expect( + resolveSandboxPublicUrl("https://inspector.example.com/sandbox", [ + "https://inspector.example.com", + ]), + ).toBeUndefined(); + expect(warned()).toContain("different origin from its host"); + }); + + it("advertises, but warns about, plain http under an https UI", () => { + expect( + resolveSandboxPublicUrl("http://sb.example.com/sandbox", [ + "https://inspector.example.com", + ]), + ).toBe("http://sb.example.com/sandbox"); + expect(warned()).toContain("mixed content"); + }); + + it("does not warn about http when every UI origin is http", () => { + resolveSandboxPublicUrl("http://sb.example.com/sandbox", UI); + expect(warned()).toBeUndefined(); + }); + }); + + describe("resolveAppOriginPublicOrigin", () => { + const SANDBOX = "https://sb.example.com/sandbox"; + const HOST = ["https://inspector.example.com"]; + + it("is unset, silently, for a blank value", () => { + expect(resolveAppOriginPublicOrigin(" ", HOST, SANDBOX)).toBeUndefined(); + expect(warned()).toBeUndefined(); + }); + + it("returns the canonical origin", () => { + expect( + resolveAppOriginPublicOrigin( + "https://Apps.Example.com/", + HOST, + SANDBOX, + ), + ).toBe("https://apps.example.com"); + expect(warned()).toBeUndefined(); + }); + + it("refuses a path, since documents are served at the origin root", () => { + expect( + resolveAppOriginPublicOrigin( + "https://apps.example.com/x", + HOST, + SANDBOX, + ), + ).toBeUndefined(); + expect(warned()).toContain("origin only"); + }); + + it("refuses a malformed value", () => { + expect( + resolveAppOriginPublicOrigin("javascript:alert(1)", HOST, SANDBOX), + ).toBeUndefined(); + expect(warned()).toContain("MCP_APP_ORIGIN_FULL_ADDRESS"); + }); + + it.each([ + ["the Inspector UI", "https://inspector.example.com"], + ["the sandbox proxy", "https://sb.example.com"], + ])("refuses an origin shared with %s", (_label, raw) => { + expect(resolveAppOriginPublicOrigin(raw, HOST, SANDBOX)).toBeUndefined(); + expect(warned()).toContain("must differ from both"); + }); + + it("checks only the UI origins when the sandbox URL is bind-derived", () => { + expect( + resolveAppOriginPublicOrigin("https://sb.example.com", HOST, undefined), + ).toBe("https://sb.example.com"); + }); + + it("warns about plain http under an https UI", () => { + expect( + resolveAppOriginPublicOrigin("http://apps.example.com", HOST, SANDBOX), + ).toBe("http://apps.example.com"); + expect(warned()).toContain("mixed content"); + }); + }); +}); diff --git a/clients/web/src/test/integration/server/run-web.test.ts b/clients/web/src/test/integration/server/run-web.test.ts index 65bd2340..bf32d88a 100644 --- a/clients/web/src/test/integration/server/run-web.test.ts +++ b/clients/web/src/test/integration/server/run-web.test.ts @@ -327,6 +327,83 @@ describe("runWeb", () => { expect(startHonoServer).not.toHaveBeenCalled(); }); + it("plumbs --protocol-era onto the seeded ad-hoc HTTP server", async () => { + void runWeb([ + "node", + "run-web", + "--server-url", + "https://example.com/mcp", + "--transport", + "http", + "--header", + "Authorization: Bearer x", + "--protocol-era", + "modern", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + type: "streamable-http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + protocolEra: "modern", + }); + }); + + it("plumbs --protocol-era onto a seeded ad-hoc stdio server", async () => { + void runWeb([ + "node", + "run-web", + "--protocol-era", + "auto", + "node", + "server.js", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + command: "node", + args: ["server.js"], + protocolEra: "auto", + }); + }); + + it("omits protocolEra from the seeded entry when the flag is absent", async () => { + void runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--transport", + "http", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).not.toHaveProperty("protocolEra"); + }); + + it("rejects an unknown --protocol-era value", async () => { + await expect( + runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--protocol-era", + "future", + ]), + ).rejects.toThrow(); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + + it("rejects --protocol-era with no ad-hoc server to attach it to", async () => { + await expect( + runWeb(["node", "run-web", "--protocol-era", "modern"]), + ).rejects.toThrow("process.exit:1"); + expect( + errorLines.some((l) => l.includes("--protocol-era requires an ad-hoc")), + ).toBe(true); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + it("exits when an ad-hoc config resolves to no entries", async () => { vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]); @@ -548,6 +625,16 @@ describe("runWeb", () => { argv: ["--config", "/a.json", "--header", "Authorization: Bearer x"], match: "--header cannot be combined with --config", }, + { + name: "--catalog + --protocol-era", + argv: ["--catalog", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --catalog", + }, + { + name: "--config + --protocol-era", + argv: ["--config", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --config", + }, ])("rejects $name", async ({ argv, match }) => { await expect(runWeb(["node", "run-web", ...argv])).rejects.toThrow( "process.exit:1", diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 6614c06e..c839bf3c 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -1,13 +1,84 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createSandboxController, DEFAULT_SANDBOX_PORT, + EMBEDDER_ORIGINS_PLACEHOLDER, + embedderOriginsLiteral, + renderSandboxProxyHtml, resolveSandboxPort, sandboxFrameAncestors, } from "../../../../server/sandbox-controller.js"; +/** A port that was free a moment ago, for tests that need a FIXED port. */ +async function freePort(): Promise { + const probe: Server = createServer(); + await new Promise((resolve) => + probe.listen(0, "127.0.0.1", () => resolve()), + ); + const addr = probe.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +const PROXY_PAGE = join( + dirname(fileURLToPath(import.meta.url)), + "../../../../static/sandbox_proxy.html", +); + +describe("embedder allow-list injection (#1862)", () => { + it("emits the same filtered list frame-ancestors admits", () => { + const origins = [ + "https://inspector.example.com", + "http://[::1]:6274", // dropped by both + "http://a:1; sandbox", // dropped by both + ]; + expect(embedderOriginsLiteral(origins)).toBe( + '["https://inspector.example.com"]', + ); + expect(sandboxFrameAncestors(origins)).toBe( + "frame-ancestors https://inspector.example.com", + ); + }); + + it.each([[undefined], [[]], [["http://[::1]:6274"]]])( + "is null — the page's loopback fallback — when nothing valid remains (%j)", + (origins) => { + expect(embedderOriginsLiteral(origins as string[] | undefined)).toBe( + "null", + ); + }, + ); + + it("escapes < so a value cannot close the :1"]); + expect(literal).not.toContain("<"); + // Still the same value once parsed back as JavaScript/JSON. + expect(JSON.parse(literal)).toEqual(["http://x:1"]); + }); + + it("does not interpret replacement patterns in the literal", () => { + expect( + renderSandboxProxyHtml(`a ${EMBEDDER_ORIGINS_PLACEHOLDER} b`, [ + "http://$&.example:1", + ]), + ).toBe('a ["http://$&.example:1"] b'); + }); + + it("finds exactly one placeholder in the shipped proxy page", () => { + // If the page's marker drifts from the constant, the substitution silently + // no-ops and every non-loopback deployment is refused again. + const page = readFileSync(PROXY_PAGE, "utf-8"); + expect(page.split(EMBEDDER_ORIGINS_PLACEHOLDER)).toHaveLength(2); + expect(page).not.toContain("ALLOWED_REFERRER_PATTERN"); + }); +}); + describe("sandboxFrameAncestors", () => { it("derives the directive from the provided allow-list", () => { expect( @@ -198,6 +269,85 @@ describe("createSandboxController", () => { } }); + it("serves the proxy page with the allow-list injected", async () => { + const controller = createSandboxController({ + port: 0, + allowedOrigins: ["https://inspector.example.com"], + }); + try { + const { url } = await controller.start(); + const body = await (await fetch(url)).text(); + expect(body).toContain( + 'const ALLOWED_EMBEDDER_ORIGINS = ["https://inspector.example.com"];', + ); + expect(body).not.toContain(EMBEDDER_ORIGINS_PLACEHOLDER); + } finally { + await controller.close(); + } + }); + + it("advertises a public URL in place of the bind-derived one (#1862)", async () => { + const publicUrl = "https://sb.example.com/sandbox"; + // A fixed port: a public URL is only advertised where a proxy can route. + const port = await freePort(); + const controller = createSandboxController({ + port, + host: "127.0.0.1", + publicUrl, + }); + try { + const first = await controller.start(); + expect(first.url).toBe(publicUrl); + expect(controller.getUrl()).toBe(publicUrl); + // The port is still the bound one — the public URL has none of its own. + expect(first.port).toBe(port); + expect( + (await fetch(`http://127.0.0.1:${first.port}/sandbox`)).status, + ).toBe(200); + // The cached second start must report the bound port, not parse one out + // of the public URL (which would be NaN here). + expect(await controller.start()).toEqual(first); + } finally { + await controller.close(); + } + }); + + it("does not advertise a public URL on an OS-assigned port (port 0)", async () => { + // MCP_SANDBOX_PORT=0 leaves no stable port for a reverse proxy to route to. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const controller = createSandboxController({ + port: 0, + host: "127.0.0.1", + publicUrl: "https://sb.example.com/sandbox", + }); + try { + const { url, port } = await controller.start(); + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"), + ); + } finally { + warnSpy.mockRestore(); + await controller.close(); + } + }); + + it("does not advertise a public URL when the listener never bound", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = createSandboxController({ + port: 0, + host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface + publicUrl: "https://sb.example.com/sandbox", + }); + try { + expect(await controller.start()).toEqual({ port: 0, url: "" }); + expect(controller.getUrl()).toBeNull(); + } finally { + errorSpy.mockRestore(); + await controller.close(); + } + }); + it("advertises localhost in the sandbox URL for a wildcard bind", async () => { // 0.0.0.0 isn't reachable from the browser, but a wildcard bind serves // loopback — so the URL handed to the client (and printed in the banner) @@ -379,6 +529,31 @@ describe("createSandboxController", () => { } }); + it("does not advertise a public URL after falling back off its pinned port (#1862)", async () => { + // The reverse proxy routes the public URL to the pinned port, which another + // process holds — so the public URL would reach the wrong listener. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const controller = createSandboxController({ + port, + host: "127.0.0.1", + publicUrl: "https://sb.example.com/sandbox", + }); + try { + const result = await controller.start(); + expect(result.port).not.toBe(port); + expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`); + expect(controller.getUrl()).toBe(result.url); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"), + ); + } finally { + warnSpy.mockRestore(); + await controller.close(); + await release(); + } + }); + it("resolves with empty values when even the dynamic retry fails", async () => { // The Vite plugin awaits start() in configureServer; if start() ever stops // resolving, the entire dev backend hangs. This pins the resolve-on-error diff --git a/clients/web/src/test/integration/server/vite-base-config.test.ts b/clients/web/src/test/integration/server/vite-base-config.test.ts index 90c39abd..2fcb88d9 100644 --- a/clients/web/src/test/integration/server/vite-base-config.test.ts +++ b/clients/web/src/test/integration/server/vite-base-config.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { clearViteDepsCache, + getStorybookOptimizeDeps, getViteBaseConfig, getViteDevOptimizeDeps, } from "../../../../server/vite-base-config.js"; @@ -54,6 +55,30 @@ describe("getViteDevOptimizeDeps", () => { }); }); +describe("getStorybookOptimizeDeps", () => { + it("forces a re-bundle on every run so a stale cache cannot re-optimize mid-run (#2340)", () => { + const config = getStorybookOptimizeDeps(); + expect(config.force).toBe(true); + }); + + it("keeps the base include and exclude sets", () => { + const config = getStorybookOptimizeDeps(); + const base = getViteBaseConfig().optimizeDeps; + expect(config.include).toEqual(base.include); + expect(config.exclude).toEqual(base.exclude); + expect(config.include).toContain("@modelcontextprotocol/client"); + }); + + it("does not carry the dev server's stale-request tolerance", () => { + // `ignoreOutdatedRequests` belongs to `vite dev`, where a full page reload + // follows a re-optimization; under Vitest a request for an outdated dep + // must fail loudly, since nothing reloads the tester iframe. + expect(getStorybookOptimizeDeps()).not.toHaveProperty( + "ignoreOutdatedRequests", + ); + }); +}); + describe("clearViteDepsCache", () => { let tempRoot: string; diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 51a56993..d3477d11 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -27,6 +27,8 @@ const MUTATED_ENV_KEYS = [ "ALLOWED_ORIGINS", "MCP_SANDBOX_PORT", "MCP_APP_ORIGIN_PORT", + "MCP_SANDBOX_FULL_ADDRESS", + "MCP_APP_ORIGIN_FULL_ADDRESS", "SERVER_PORT", "MCP_LOG_FILE", "MCP_AUTO_OPEN_ENABLED", @@ -342,6 +344,40 @@ describe("buildWebServerConfigFromEnv", () => { }, ); + it("leaves the public addresses unset by default (#1862)", () => { + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.sandboxPublicUrl).toBeUndefined(); + expect(cfg.appOriginPublicOrigin).toBeUndefined(); + }); + + it("resolves both public addresses against the resolved allow-list", () => { + process.env.ALLOWED_ORIGINS = "https://inspector.example.com"; + process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com"; + process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://apps.example.com"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox"); + expect(cfg.appOriginPublicOrigin).toBe("https://apps.example.com"); + }); + + it("refuses public addresses that collapse onto the Inspector or sandbox origin", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // The DEFAULT allow-list is what the sandbox is checked against when + // ALLOWED_ORIGINS is unset — so the loopback UI origin is refused too. + process.env.MCP_SANDBOX_FULL_ADDRESS = "http://localhost:6274/sandbox"; + expect(buildWebServerConfigFromEnv().sandboxPublicUrl).toBeUndefined(); + + // The app origin is checked against the overridden sandbox's origin. + process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com/sandbox"; + process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://sb.example.com"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox"); + expect(cfg.appOriginPublicOrigin).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + } + }); + it("leaves an explicit dynamic app-origin port alone", () => { // 0 means "OS-assigned" for both, so two zeros are not a collision. process.env.CLIENT_PORT = "6274"; diff --git a/clients/web/src/test/renderWithMantine.tsx b/clients/web/src/test/renderWithMantine.tsx index 677d9ca0..a14834fd 100644 --- a/clients/web/src/test/renderWithMantine.tsx +++ b/clients/web/src/test/renderWithMantine.tsx @@ -108,12 +108,46 @@ export function renderWithMantineTransitions( return { ...result, unmount: downgradingUnmount }; } -// Fallback settle window: ≈500ms clears a typical few-hundred-millisecond -// transition plus the two-frame rAF scheduling slack. Callers that know their -// component's animation duration should pass `settleMs` (its longest JS timer -// chain — duration plus any `enterDelay`/`exitDelay` — plus slack) rather than -// rely on this default. -const DEFAULT_SETTLE_MS = 500; +// A settle window is two terms with completely different relationships to the +// machine, and they must not be scaled as one number (#2323). +// +// The first term tracks the component: the longest JS timer chain its +// transition schedules — its duration plus any `enterDelay`/`exitDelay`. That +// is fixed by the component and load does not move it. Callers that know their +// component derive it from the component's own exported constant, which is why +// `ViewHeader.test.tsx` writes `HEADER_ANIM_MS + RAF_SLACK_MS` rather than a +// literal: bumping the animation then cannot silently outrun the settle. +// +// The second term is this one — the slack in which the rAF callbacks, the React +// commit and the terminal `setTimeout` they schedule actually have to *run*. +// That work is CPU-bound, so it is the term an oversubscribed machine eats, and +// the only term that should grow when the machine gets busier. +// +// ⚠️ Unlike a timeout, this is a fixed sleep: it always waits the full window +// and never exits early. Raising it therefore costs every *passing* run (3 call +// sites x 300ms ≈ 0.9s), which is the opposite call from the one made for the +// repo's ordinary fixed sleeps. It is justified here only because this failure +// is silent and displaced rather than loud and local: an insufficient settle +// fails nothing at its own site — the test's assertions have already passed — +// and surfaces either as a leaked timer that `setup.ts` cancels with no report +// at all, or as an uncaught post-teardown `window is not defined` failing the +// whole run from an arbitrary innocent file (#1760). Re-running localizes +// neither. Do not generalize this to the ordinary sleeps. +// +// (A condition wait is not available and should not be re-litigated: a +// completed *enter* transition leaves no DOM signal to `waitFor` on, which is +// the whole reason this is a sleep at all.) +export const RAF_SLACK_MS = 500; + +// The animation term of the fallback window, for a caller that does not know +// its component's duration. A typical Mantine transition is a few hundred ms. +const TYPICAL_ANIM_MS = 300; + +// Fallback settle window, used by `settleTransitions()` called with no +// argument. Built from the same slack constant as the derived call sites so the +// two cannot drift apart. Callers that know their component's animation +// duration should pass `settleMs` instead of relying on this. +const DEFAULT_SETTLE_MS = TYPICAL_ANIM_MS + RAF_SLACK_MS; // Set by `renderWithMantineTransitions`; consumed once by the `afterEach` below. // `armedSettleMs === null` means no real-transitions render happened this test, @@ -165,8 +199,12 @@ function resetArming() { // `setTimeout`, and the React work they schedule against the still-mounted tree // — whichever is the actual escapee, it resolves on a live component before // cleanup unmounts. Because it awaits a *real* `setTimeout`, it deadlocks under -// fake timers, so guard against that with a clear message rather than a 5s -// test-timeout hang. +// fake timers, so guard against that with a clear message rather than leaving +// it to the project's `hookTimeout` — which is what actually bounds this await, +// since the auto-settle runs in an `afterEach`. That is 30000ms +// (`vitest.shared.mts`, #2323), so without this throw a fake-timer deadlock +// would take half a minute to report something naming neither transitions nor +// fake timers. export async function settleTransitions(ms: number = DEFAULT_SETTLE_MS) { if (vi.isFakeTimers()) { throw new Error( diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index c86b3634..dae9776c 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -1,6 +1,49 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; +import { cleanup, configure } from "@testing-library/react"; + +// Testing Library's `asyncUtilTimeout` governs every `waitFor` and `findBy*` in +// this project — 788 call sites — and is the tighter of the two bounds on any +// async assertion here, so it is stated rather than inherited (#2323). Pinning +// it also means a Testing Library upgrade that changes its default cannot move +// every one of those sites silently. +// +// ⚠️ The value is 1000 — Testing Library's own default — and that is a +// MEASURED decision, not a shrug. #2323 proposed 5000 on the reasoning that a +// contended happy-dom render is the worst case and that this becomes the +// binding constraint once the project's `testTimeout` rises. Measured on this +// machine, 5000 made the suite WORSE: three arms of three full unit runs each, +// same worktree, comparable load — +// +// • unmodified `v2/main`: 3/3 green +// • this change with 5000: 1, 4 and 0 files timing out at 15000ms +// • this change with the default: 3/3 green +// +// The failures were a different, unrelated set each time and every one passed +// in about a second in isolation, which is the shape of CPU starvation rather +// than of a slow assertion. The mechanism proposed at the time was that +// `asyncUtilTimeout` is not only a ceiling: a wait that is *meant* to expire — +// a test asserting something never appears, a poll allowed to run out — spends +// the whole budget on the happy path, so a 5x raise would be a 5x cost on +// those tests, saturating the worker pool. +// +// #2335 went looking for those tests and found none. Zero hits for every +// expiring-wait shape against Testing Library across the unit, `core/`, +// integration and story files; no test at or above 2000ms in a full unit run; +// and the same three-arm protocol re-run interleaved on a leased machine +// (1000 / 5000 / reverted, three rounds) gave 0 failures in every arm — the +// one red test in each 5000 arm being `asyncUtilTimeout.test.ts` refusing the +// raised value, which is the guard doing its job — and 0 tests at or above +// 5000ms with the raise in place. Nothing on the passing +// path spends this budget, so raising it buys nothing — and the #2323 reds +// read as ambient load the arms did not share (an inference from that data, +// not a re-measurement of the original run). +// +// So the general rule this repo now follows — a budget must be a value someone +// chose — is satisfied by choosing it, not by raising it. The pin stays at the +// default until a measurement says otherwise; `asyncUtilTimeout.test.ts` +// asserts the effective value, so moving it is deliberate by construction. +configure({ asyncUtilTimeout: 1000 }); // Node 22+ exposes an experimental `localStorage` placeholder that overrides // happy-dom's implementation. Without `--localstorage-file`, it's an empty diff --git a/clients/web/src/theme/Code.test.tsx b/clients/web/src/theme/Code.test.tsx new file mode 100644 index 00000000..a78fea4b --- /dev/null +++ b/clients/web/src/theme/Code.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { Code } from "@mantine/core"; +import { renderWithMantine } from "../test/renderWithMantine"; + +// `ThemeCode.extend` is applied transitively wherever a Code renders, but no +// single component exercises every branch of its styles callback. Same reason +// as `Paper.test.tsx` (#1787 brought theme/** under the coverage gate). +// +// The load-bearing assertion here is the `wrapping` / `wrapping-plain` split +// (#2328): `wrapping-plain` exists so Connection Info can drop the inset +// surface behind a label/value pair *without* changing `wrapping`, which +// ElicitationUrlPanel, ListLoadError, MalformedItemsWarning and the CSV +// fallback all rely on to separate a raw value from the prose around it. + +describe("ThemeCode variants", () => { + it("renders the default variant", () => { + const { getByText } = renderWithMantine(default); + expect(getByText("default")).toBeTruthy(); + }); + + it("wraps long values in both wrapping variants", () => { + for (const variant of ["wrapping", "wrapping-plain"] as const) { + const { getByText } = renderWithMantine( + {variant}, + ); + const style = getByText(variant).style; + expect(style.wordBreak).toBe("break-all"); + expect(style.whiteSpace).toBe("pre-wrap"); + } + }); + + it("strips the inset surface for wrapping-plain only", () => { + const { getByText: getPlain } = renderWithMantine( + plain, + ); + expect(getPlain("plain").style.backgroundColor).toBe("transparent"); + expect(getPlain("plain").style.padding).toBe("0px"); + + // The guard that makes this change safe: `wrapping` keeps its surface, so + // every other consumer of it is untouched. + const { getByText: getInset } = renderWithMantine( + inset, + ); + expect(getInset("inset").style.backgroundColor).not.toBe("transparent"); + expect(getInset("inset").style.padding).not.toBe("0px"); + }); + + it("clips instead of wrapping for the nowrap variant", () => { + const { getByText } = renderWithMantine(n); + const style = getByText("n").style; + expect(style.whiteSpace).toBe("nowrap"); + expect(style.overflow).toBe("hidden"); + expect(style.textOverflow).toBe("ellipsis"); + }); + + it("drops the margin on a block Code", () => { + const { getByText } = renderWithMantine(b); + expect(getByText("b").style.margin).toBe("0px"); + }); +}); diff --git a/clients/web/src/theme/Code.ts b/clients/web/src/theme/Code.ts index b54a8eee..86efd014 100644 --- a/clients/web/src/theme/Code.ts +++ b/clients/web/src/theme/Code.ts @@ -10,10 +10,20 @@ export const ThemeCode = Code.extend({ backgroundColor: "var(--inspector-code-surface, var(--inspector-surface-code))", }; - if (props.variant === "wrapping") { + if (props.variant === "wrapping" || props.variant === "wrapping-plain") { root.wordBreak = "break-all"; root.whiteSpace = "pre-wrap"; } + if (props.variant === "wrapping-plain") { + // Wraps like `wrapping`, but with no inset surface behind it. Used for + // label/value rows that read as *values* rather than as quoted blobs — + // Connection Info's Client ID and Auth URL. A separate variant rather + // than a tweak to `wrapping`, because that one is shared with panels + // (elicitation URLs, load errors, CSV fallback) where the grey inset is + // doing real work separating a raw value from the prose around it. + root.backgroundColor = "transparent"; + root.padding = "0"; + } if (props.block) { root.margin = "0"; } diff --git a/clients/web/src/utils/connectionActivity.test.ts b/clients/web/src/utils/connectionActivity.test.ts new file mode 100644 index 00000000..db31480e --- /dev/null +++ b/clients/web/src/utils/connectionActivity.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + formatLastResponse, + formatNotificationStream, + formatOutstandingRequests, + NO_NOTIFICATION_STREAM_LABEL, + NO_RESPONSE_YET_LABEL, + NO_STREAM_ON_STDIO_LABEL, +} from "./connectionActivity"; + +const NOW = 1_000_000; +const seconds = (n: number) => n * 1000; + +describe("formatOutstandingRequests", () => { + it("renders one line per request, oldest first, against the given clock", () => { + expect( + formatOutstandingRequests( + { + capturedAt: NOW, + outstandingRequests: [ + { id: 1, method: "tools/list", sentAt: NOW - seconds(60) }, + { id: 2, method: "ping", sentAt: NOW - seconds(12) }, + ], + }, + NOW, + ), + ).toEqual(["tools/list — sent 1m00s ago", "ping — sent 12s ago"]); + }); + + it("renders nothing when nothing is outstanding", () => { + expect( + formatOutstandingRequests( + { capturedAt: NOW, outstandingRequests: [] }, + NOW, + ), + ).toEqual([]); + }); +}); + +describe("formatLastResponse", () => { + it("names the method and the age", () => { + expect( + formatLastResponse( + { + capturedAt: NOW, + outstandingRequests: [], + lastResponse: { method: "initialize", receivedAt: NOW - 500 }, + }, + NOW, + ), + ).toBe("initialize — 500ms ago"); + }); + + it("says none yet before any response", () => { + expect( + formatLastResponse({ capturedAt: NOW, outstandingRequests: [] }, NOW), + ).toBe(NO_RESPONSE_YET_LABEL); + }); +}); + +describe("the clock argument", () => { + it("moves every duration with it, so a ticking caller renders live values", () => { + const diagnostics = { + capturedAt: NOW, + outstandingRequests: [ + { id: 1, method: "tools/list", sentAt: NOW - seconds(5) }, + ], + lastResponse: { method: "initialize", receivedAt: NOW - seconds(6) }, + }; + expect(formatOutstandingRequests(diagnostics, NOW + seconds(55))).toEqual([ + "tools/list — sent 1m00s ago", + ]); + expect(formatLastResponse(diagnostics, NOW + seconds(55))).toBe( + "initialize — 1m01s ago", + ); + }); +}); + +describe("formatNotificationStream", () => { + const open = { + url: "http://h/mcp", + openedAt: NOW - seconds(252), + eventCount: 1, + }; + + it("is not applicable on stdio, whatever the snapshot says", () => { + expect(formatNotificationStream(open, "stdio", NOW)).toBe( + NO_STREAM_ON_STDIO_LABEL, + ); + }); + + it("says not opened when no stream has been tracked", () => { + expect(formatNotificationStream(undefined, "sse", NOW)).toBe( + NO_NOTIFICATION_STREAM_LABEL, + ); + }); + + it("describes an open stream by endpoint, age and event count", () => { + expect(formatNotificationStream(open, "streamable-http", NOW)).toBe( + "GET /mcp — open for 4m12s, 1 event delivered", + ); + }); + + it("describes a closed stream by when it closed and how long it lasted", () => { + expect( + formatNotificationStream( + { ...open, eventCount: 2, closedAt: NOW - seconds(5) }, + "streamable-http", + NOW, + ), + ).toBe("GET /mcp — closed 5s ago after 4m07s, 2 events delivered"); + }); +}); diff --git a/clients/web/src/utils/connectionActivity.ts b/clients/web/src/utils/connectionActivity.ts new file mode 100644 index 00000000..649785f4 --- /dev/null +++ b/clients/web/src/utils/connectionActivity.ts @@ -0,0 +1,63 @@ +// Row formatters for the Connection Info modal's Connection Activity section +// (#2318): the same snapshot a request timeout's message is written from, +// rendered while the request is still in flight. Pure functions over the +// snapshot and an explicit clock — the caller decides what "now" is (the +// snapshot's own `capturedAt` on first paint, a ticking clock afterwards; see +// `useTickingClock`), so nothing here reads the wall clock and a render stays +// a pure function of its inputs. + +import { + describeStreamEndpoint, + formatDuration, + type ConnectionDiagnostics, + type NotificationStreamState, +} from "@inspector/core/mcp/connectionDiagnostics.js"; +import type { ServerType } from "@inspector/core/mcp/types.js"; + +export const NO_OUTSTANDING_REQUESTS_LABEL = "None"; +export const NO_RESPONSE_YET_LABEL = "None this session"; +export const NO_NOTIFICATION_STREAM_LABEL = "Not opened"; +export const NO_STREAM_ON_STDIO_LABEL = "N/A (stdio)"; + +/** + * One line per unanswered request, oldest first — `tools/list — sent 60s + * ago`. A request the SDK has already given up on stays listed: it is still + * unanswered, which is the fact this section reports. + */ +export function formatOutstandingRequests( + diagnostics: ConnectionDiagnostics, + now: number, +): string[] { + return diagnostics.outstandingRequests.map( + (request) => + `${request.method} — sent ${formatDuration(now - request.sentAt)} ago`, + ); +} + +export function formatLastResponse( + diagnostics: ConnectionDiagnostics, + now: number, +): string { + const last = diagnostics.lastResponse; + if (!last) return NO_RESPONSE_YET_LABEL; + return `${last.method} — ${formatDuration(now - last.receivedAt)} ago`; +} + +/** + * The notification stream row. stdio has no HTTP stream at all, so the row + * says so rather than "Not opened", which would read as a fault. + */ +export function formatNotificationStream( + stream: NotificationStreamState | undefined, + transport: ServerType, + now: number, +): string { + if (transport === "stdio") return NO_STREAM_ON_STDIO_LABEL; + if (!stream) return NO_NOTIFICATION_STREAM_LABEL; + const endpoint = describeStreamEndpoint(stream.url); + const events = `${stream.eventCount} event${stream.eventCount === 1 ? "" : "s"} delivered`; + if (stream.closedAt !== undefined) { + return `${endpoint} — closed ${formatDuration(now - stream.closedAt)} ago after ${formatDuration(stream.closedAt - stream.openedAt)}, ${events}`; + } + return `${endpoint} — open for ${formatDuration(now - stream.openedAt)}, ${events}`; +} diff --git a/clients/web/src/utils/serverSettingsDefaults.test.ts b/clients/web/src/utils/serverSettingsDefaults.test.ts index 240b4608..ee653e7e 100644 --- a/clients/web/src/utils/serverSettingsDefaults.test.ts +++ b/clients/web/src/utils/serverSettingsDefaults.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, } from "@inspector/core/mcp/types.js"; @@ -11,7 +12,7 @@ describe("EMPTY_SETTINGS", () => { headers: [], env: [], metadata: {}, - connectionTimeout: 0, + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: false, diff --git a/clients/web/src/utils/serverSettingsDefaults.ts b/clients/web/src/utils/serverSettingsDefaults.ts index 522b3d1e..72dbbf12 100644 --- a/clients/web/src/utils/serverSettingsDefaults.ts +++ b/clients/web/src/utils/serverSettingsDefaults.ts @@ -1,5 +1,6 @@ import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { + DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, } from "@inspector/core/mcp/types.js"; @@ -14,7 +15,7 @@ export const EMPTY_SETTINGS: InspectorServerSettings = { headers: [], env: [], metadata: {}, - connectionTimeout: 0, + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: false, diff --git a/clients/web/src/utils/uncapturedBodyNote.test.ts b/clients/web/src/utils/uncapturedBodyNote.test.ts new file mode 100644 index 00000000..1bb88b0e --- /dev/null +++ b/clients/web/src/utils/uncapturedBodyNote.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; +import { + isLongLivedStreamEntry, + longLivedStreamLabel, + uncapturedBodyNote, +} from "./uncapturedBodyNote"; + +const streamEntry: FetchRequestEntry = { + id: "n-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "GET", + url: "https://example.com/mcp", + requestHeaders: {}, + responseStatus: 200, + responseHeaders: { "content-type": "text/event-stream" }, + category: "transport", +}; + +describe("isLongLivedStreamEntry", () => { + it("is true for a GET answered with an event stream and false otherwise", () => { + expect(isLongLivedStreamEntry(streamEntry)).toBe(true); + expect(isLongLivedStreamEntry({ ...streamEntry, method: "POST" })).toBe( + false, + ); + expect( + isLongLivedStreamEntry({ ...streamEntry, responseHeaders: undefined }), + ).toBe(false); + }); + + it("reads the content type whatever casing the entry recorded it with", () => { + expect( + isLongLivedStreamEntry({ + ...streamEntry, + responseHeaders: { "Content-Type": "text/event-stream" }, + }), + ).toBe(true); + }); +}); + +describe("longLivedStreamLabel", () => { + it("names the framing the stream uses", () => { + expect(longLivedStreamLabel(streamEntry)).toBe("SSE"); + expect( + longLivedStreamLabel({ + ...streamEntry, + responseHeaders: { "Content-Type": "application/x-ndjson" }, + }), + ).toBe("NDJSON"); + }); +}); + +describe("uncapturedBodyNote", () => { + it("says (empty) for a bounded response", () => { + expect(uncapturedBodyNote({ ...streamEntry, method: "POST" })).toBe( + "(empty)", + ); + }); + + it("says only that the body was not captured before the stream is watched", () => { + expect(uncapturedBodyNote(streamEntry)).toBe( + "Long-lived stream — body not captured", + ); + }); + + it("reports the event count and open state, with the singular for one event", () => { + expect( + uncapturedBodyNote({ ...streamEntry, stream: { eventCount: 3 } }), + ).toBe( + "Long-lived stream — 3 events delivered, still open; body not captured", + ); + expect( + uncapturedBodyNote({ + ...streamEntry, + stream: { eventCount: 1, closedAt: new Date() }, + }), + ).toBe("Long-lived stream — 1 event delivered, closed; body not captured"); + }); +}); diff --git a/clients/web/src/utils/uncapturedBodyNote.ts b/clients/web/src/utils/uncapturedBodyNote.ts new file mode 100644 index 00000000..e8e886fb --- /dev/null +++ b/clients/web/src/utils/uncapturedBodyNote.ts @@ -0,0 +1,50 @@ +// The Network entry's body placeholder for a response whose body was not +// captured (#2318). Pure: a string from the entry alone. + +import { + findHeader, + isLongLivedStreamResponse, + longLivedStreamFraming, +} from "@inspector/core/mcp/fetchTracking.js"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; + +/** + * Whether the entry is an unbounded server-to-client stream (never + * buffered). The header lookup is case-insensitive: a recorded entry keeps + * the wire casing (`Content-Type` on some hosts, and in a restored session), + * and a miss here would silently reclassify the stream as a bounded body. + */ +export function isLongLivedStreamEntry(entry: FetchRequestEntry): boolean { + return isLongLivedStreamResponse( + entry.method, + findHeader(entry.responseHeaders, "content-type"), + ); +} + +/** + * The badge label for a long-lived stream entry — `SSE` or `NDJSON`, by the + * same content-type split the tracker counts events with. + */ +export function longLivedStreamLabel(entry: FetchRequestEntry): string { + return longLivedStreamFraming( + findHeader(entry.responseHeaders, "content-type"), + ) === "ndjson" + ? "NDJSON" + : "SSE"; +} + +/** + * What stands in for a body that was not captured. A long-lived stream is + * never buffered, so its placeholder reports what the tracker learned by + * watching it: how many events it delivered and whether it is still open. + * Before the first update, or for a log restored from a session that + * predates the field, the note says only that the body was not captured. + */ +export function uncapturedBodyNote(entry: FetchRequestEntry): string { + if (!isLongLivedStreamEntry(entry)) return "(empty)"; + const stream = entry.stream; + if (!stream) return "Long-lived stream — body not captured"; + const events = `${stream.eventCount} event${stream.eventCount === 1 ? "" : "s"} delivered`; + const state = stream.closedAt ? "closed" : "still open"; + return `Long-lived stream — ${events}, ${state}; body not captured`; +} diff --git a/clients/web/static/sandbox_proxy.html b/clients/web/static/sandbox_proxy.html index 5777c474..203f9024 100644 --- a/clients/web/static/sandbox_proxy.html +++ b/clients/web/static/sandbox_proxy.html @@ -76,8 +76,19 @@ return allowList.join("; "); } - const ALLOWED_REFERRER_PATTERN = - /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/; + // The exact embedder origins this proxy accepts, injected by the sandbox + // controller when it serves this page (see `embedderOriginsLiteral` in + // server/sandbox-controller.ts). It is the same list the served + // `frame-ancestors` header admits, so the two checks agree by + // construction — an https or public-hostname Inspector page is accepted + // exactly when the backend's ALLOWED_ORIGINS names it (#1862). + // + // `null` means the controller had no valid list (a hand-constructed + // caller or a test); the header then falls back to the loopback family, + // and so does this check. + const ALLOWED_EMBEDDER_ORIGINS = /*INSPECTOR_EMBEDDER_ORIGINS*/ null; + const LOOPBACK_EMBEDDER_PATTERN = + /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; if (window.self === window.top) { throw new Error("This file is only to be used in an iframe sandbox."); @@ -87,9 +98,24 @@ throw new Error("No referrer, cannot validate embedding site."); } - if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) { + // Compare the referrer's parsed ORIGIN, never the raw string: a pattern + // over the full referrer is a prefix/substring match an attacker page's + // path or query can be shaped to satisfy. + let referrerOrigin = null; + try { + referrerOrigin = new URL(document.referrer).origin; + } catch (e) { + referrerOrigin = null; + } + + if ( + !referrerOrigin || + !(Array.isArray(ALLOWED_EMBEDDER_ORIGINS) + ? ALLOWED_EMBEDDER_ORIGINS.includes(referrerOrigin) + : LOOPBACK_EMBEDDER_PATTERN.test(referrerOrigin)) + ) { throw new Error( - `Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`, + `Embedding origin ${referrerOrigin} is not allowed to load the MCP Apps sandbox. Add it to the Inspector's ALLOWED_ORIGINS (e.g. ALLOWED_ORIGINS=https://inspector.example.com) and restart.`, ); } diff --git a/clients/web/tsconfig.test.json b/clients/web/tsconfig.test.json index e12c27bd..713badf3 100644 --- a/clients/web/tsconfig.test.json +++ b/clients/web/tsconfig.test.json @@ -41,7 +41,13 @@ "src/test/**/*.ts", "src/test/**/*.tsx", "../../core/**/__tests__/**/*.ts", - "../../core/**/__tests__/**/*.tsx" + "../../core/**/__tests__/**/*.tsx", + // The shared no-retry setup every Vitest project loads (#2323). It is + // referenced by the configs as a resolved PATH rather than imported, so no + // project's tsconfig picks it up transitively the way `vitest.shared.mts` + // is picked up through `vitest.config.ts` — it needs naming, or it lands in + // no tsconfig project at all and `verify:typecheck-coverage` fails. + "../../vitest.setup.shared.mts" ], "exclude": [] } diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 97034569..ac7f541b 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -9,12 +9,18 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { playwright } from "@vitest/browser-playwright"; import { honoMiddlewarePlugin } from "./server/vite-hono-plugin"; import { + getStorybookOptimizeDeps, getViteBaseConfig, getViteDevOptimizeDeps, } from "./server/vite-base-config"; import { buildWebServerConfigFromEnv } from "./server/web-server-config"; import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { + INTEGRATION_TIMEOUTS, + NO_RETRY_SETUP, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; const dirname = typeof __dirname !== "undefined" ? __dirname @@ -314,7 +320,10 @@ export default defineConfig(({ command }) => { include: ["clients/web/src/**/*.test.{ts,tsx}"], // Integration tests run in the integration project below (node env). exclude: [integrationGlob], - setupFiles: [path.join(dirname, "src/test/setup.ts")], + setupFiles: [ + path.join(dirname, "src/test/setup.ts"), + NO_RETRY_SETUP, + ], // Pin after-hooks to LIFO (reverse registration). This is Vitest 4's // own default (`resolved.sequence.hooks ??= "stack"` — the CLI // help-text's "parallel" is stale), so this line documents intent and @@ -330,6 +339,12 @@ export default defineConfig(({ command }) => { // `container.isConnected` self-checks are the real guard against a // future regression. sequence: { hooks: "stack" }, + // Shared budgets (#2323). This is the largest test surface in the + // repo — 342 files — and every one of them ran on Vitest's own + // 5000ms until now. The single site that had been patched by hand + // (`App.test.tsx`'s three-modal sequence) named that default as its + // reason for existing. + ...TIMEOUTS, }, }, { @@ -347,11 +362,15 @@ export default defineConfig(({ command }) => { // can transform core/ modules and run tests against the source. root: repoRoot, include: [integrationGlob], + setupFiles: [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. - testTimeout: 30000, - hookTimeout: 30000, + // storage. 30s matches the v1.5 core/vitest.config.ts. Now stated + // once in `vitest.shared.mts` (#2323) rather than here, together + // with the `teardownTimeout` that was left on the 10000ms default — + // teardown is where these suites unlink that filesystem-backed + // storage and reap the servers they spawned. + ...INTEGRATION_TIMEOUTS, // Inline the MCP SDK so vi.mock("@modelcontextprotocol/client") // hooks the same transformed copy that source files import. // Externalized node_modules are loaded via Node's loader and bypass @@ -373,6 +392,10 @@ export default defineConfig(({ command }) => { configDir: path.join(dirname, ".storybook"), }), ], + // Re-bundled on every run rather than read from the cache — the + // stale-cache failure it prevents, and why `force` is the lever, are + // on the helper (#2340). + optimizeDeps: getStorybookOptimizeDeps(), test: { name: "storybook", // Vitest's default is 5000ms, which is the whole budget a play @@ -384,7 +407,18 @@ export default defineConfig(({ command }) => { // (#2292). A larger ceiling does not hide a defect here: a story // that blows 15s has genuinely failed, and every assertion stays as // strict as it was. - testTimeout: 15000, + // …which is the shared `TIMEOUTS.testTimeout` today (#2323): this + // project is where the value was first chosen, and the other five + // now follow it rather than restate it. The hook and teardown + // budgets come along with it, having been left on the defaults. + ...TIMEOUTS, + // Carries the shared no-retry assertion and nothing else. + // ⚠️ It is deliberately NOT in `.storybook/` and declares no + // annotations: `@storybook/addon-vitest` skips its automatic + // preview-annotation provisioning only for a setup file that is + // both inside `configDir` and calls `setProjectAnnotations`, and + // this one is neither (#1898). + setupFiles: [NO_RETRY_SETUP], browser: { enabled: true, headless: true, @@ -395,12 +429,15 @@ export default defineConfig(({ command }) => { }, ], }, - // No `setupFiles`: since Storybook 10.3 `@storybook/addon-vitest` + // The `setupFiles` above is the project's only one, and it + // carries no preview annotations: since Storybook 10.3 + // `@storybook/addon-vitest` // provisions the preview annotations (`.storybook/preview.tsx` plus // `@storybook/addon-a11y/preview`) itself, and *skips* doing so when - // it finds a setup file calling `setProjectAnnotations` — so the old - // `.storybook/vitest.setup.ts` was both redundant and actively - // opting out of the automatic path (#1898). A green suite doesn't + // it finds a setup file that is both inside `configDir` and calls + // `setProjectAnnotations` — so the old `.storybook/vitest.setup.ts` + // was both redundant and actively opting out of the automatic path + // (#1898). A green suite doesn't // prove the automatic provisioning works (stories rendered without // the Mantine decorator would very likely still pass), so // `src/test/PreviewAnnotations.stories.tsx` asserts it directly. diff --git a/core/auth/index.ts b/core/auth/index.ts index c74702ce..918eaac3 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -51,6 +51,18 @@ export { withRfc8414OidcCompat, } from "./oidcDiscoveryCompat.js"; +// OAuth-path request deadline (#2319) +export { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + OAUTH_TIMEOUT_WIRE_CODE, + OAuthRequestTimeoutError, + deadlineForRequestInit, + exemptMcpEndpoint, + isOAuthRequestTimeoutWire, + withOAuthRequestTimeout, +} from "./requestTimeout.js"; +export type { OAuthRequestTimeoutWire } from "./requestTimeout.js"; + // Storage export type { OAuthStorage, diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index bb30e722..ce0ace87 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -90,6 +90,7 @@ * wiring in `core/mcp/inspectorClient.ts`. */ +import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js"; import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, @@ -169,6 +170,22 @@ async function releaseBody(response: Response): Promise { await response.body?.cancel().catch(() => {}); } +/** + * The same release, started but not waited on. + * + * ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel promise, + * which is permitted never to settle — so awaiting it on a path that is + * *propagating a cancellation* can hang the very thing that was meant to end + * (Copilot). Every exceptional exit below uses this: the release is a courtesy + * to the connection pool, and the caller's abort or timeout must reach it + * regardless. The `void` is the documented case where the callee owns its + * failures — `releaseBody` swallows its own — and the caller genuinely cannot + * await. + */ +function releaseBodyDetached(response: Response): void { + void releaseBody(response); +} + /** * Whether a parsed body is RFC 8414 authorization-server metadata that is *not* * a valid OpenID provider document — the exact shape the upstream schema @@ -248,6 +265,13 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { if (candidates.length === 0) return response; const headers = discoveryHeaders(input, init); + // The probe is a request the caller never made, issued on its behalf — so + // it has to be cancellable by the caller too. Without the signal, aborting + // after the initial 404 left the probe running to its own deadline and the + // caller was handed a timeout in place of its own abort (Copilot). Same + // precedence rule as everywhere else, hence the shared helper rather than a + // second copy of it. + const callerSignal = callerSignalOf(input, init); // The loop advances to the next candidate only where the SDK's own loop // would. Anywhere else it hands the original response back and lets // discovery run its normal course, because *promoting a later candidate @@ -259,10 +283,50 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // those, returning `response` leaves the SDK to make the same request and // reach the same verdict it always would. for (const candidate of candidates) { + // Don't issue a probe the caller has already given up on. A real `fetch` + // rejects immediately on a pre-aborted signal, so this is mostly about + // not sending the request at all — the same reasoning as the + // short-circuit in `withOAuthRequestTimeout`. + // + // Every exceptional exit below releases `response` first. On a normal + // return it is the value the caller gets and reads; on a throw nobody + // will ever read it, and an unread body holds its connection open on + // Node/undici — the same reason the successful-substitution path + // releases it (Copilot). Matters most for a standalone user of this + // exported wrapper, where no timeout wrapper is underneath to have + // already drained it. + if (callerSignal?.aborted) { + releaseBodyDetached(response); + throw callerSignal.reason; + } + let probe: Response; try { - probe = await fetchFn(candidate, { headers }); - } catch { + probe = await fetchFn(candidate, { headers, signal: callerSignal }); + } catch (err) { + // A deadline the Inspector imposed is *our* error, not the server's + // answer, so it escapes rather than being folded back into the original + // response (#2319, Copilot). Swallowing it would hand the caller the + // preceding 404 and discard the one thing the deadline adds — the name + // of the endpoint that stalled — leaving discovery to fail later under + // a message that points at the wrong URL. There is no flow this can + // break that was not already broken: every candidate this loop probes + // is one the SDK's own `buildDiscoveryUrls` emits, so a stall here is a + // stall the SDK would have hit itself, unbounded. Everything else — a + // CORS rejection, DNS, a reset — still falls back as documented above. + if (err instanceof OAuthRequestTimeoutError) { + releaseBodyDetached(response); + throw err; + } + // Likewise a caller that gave up: substituting the preceding 404 for + // its own abort would report a discovery failure for a request it + // deliberately cancelled. The caller's own `reason` is what surfaces, + // here and at the two sites below, rather than whatever shape the + // underlying layer happened to reject with. + if (callerSignal?.aborted) { + releaseBodyDetached(response); + throw callerSignal.reason; + } return response; } if (!probe.ok) { @@ -272,6 +336,14 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // candidate exhaust the origin's pool (Copilot). Same discipline as // `core/mcp/node/authChallengeFetch.ts`. await releaseBody(probe); + // Rechecked after the release, which awaits: an abort that lands while + // the body is being discarded must not be answered with the preceding + // response either, and `continue` would otherwise carry on probing for + // a caller that has stopped waiting. + if (callerSignal?.aborted) { + releaseBodyDetached(response); + throw callerSignal.reason; + } if (continuesDiscovery(probe.status)) continue; return response; } @@ -288,6 +360,17 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { body = await probe.text(); parsed = JSON.parse(body); } catch { + // `fetch` resolves on headers, so the abort can land here rather than + // in the catch above: the probe's headers arrived, the caller gave up, + // and `probe.text()` rejects on the partial body. Substituting the + // preceding 404 would hand a standalone user of this wrapper a + // discovery failure in place of its own abort reason (Copilot). A body + // that merely will not parse still falls through, which is the case + // this catch exists for. + if (callerSignal?.aborted) { + releaseBodyDetached(response); + throw callerSignal.reason; + } return response; } if (!isRfc8414OnlyMetadata(parsed)) { diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts new file mode 100644 index 00000000..7b771919 --- /dev/null +++ b/core/auth/requestTimeout.ts @@ -0,0 +1,577 @@ +/** + * A bound on every network call the Inspector makes on the OAuth path (#2319). + * + * ## Why this exists + * + * Protected-resource-metadata discovery, authorization-server metadata + * discovery, dynamic client registration, the token exchange and the refresh + * all went out with no `AbortSignal` at all. Token revocation was the single + * exception — `revocation.ts` has carried its own deadline since #2144, and it + * is the pattern this module generalizes. + * + * An unbounded fetch matters more here than it usually would, because of where + * this work runs and what the UI does while it runs: + * + * - It runs **server-side**, in the Inspector's own Node process, proxied + * through the backend. A stall is therefore invisible in browser devtools — + * #2188's reporter correctly observed that no request left the browser at + * all while a Node-side socket sat established and idle. + * - Connect-time auth errors deliberately hold the connection status at + * `"connecting"` rather than moving it to `"error"` + * (`isConnectAuthRecoveryError`, `challenge.ts`), on the theory that a + * redirect is about to end the attempt. A stalled fetch inside that window is + * a silent, unbounded spinner rather than a surfaced failure. + * + * The deadline covers the **whole exchange**, not just the headers: `fetch` + * resolves as soon as response headers arrive, so a server that sends headers + * — or half a JSON document — and then stalls would leave the caller's + * `response.json()` hanging with nothing watching it. Every response on this + * path is a small finite document, so a *clone* of it is drained under the same + * race and the original — untouched, with every native slot intact — is what + * the caller gets. + * + * Today the common case is bounded, but only incidentally: a discovery stall + * that happens to sit inside an SDK `initialize` rides that request's own + * timeout and surfaces after 60s as `Request timed out`. This module makes the + * bound deliberate, applies it to the stalls that sit *outside* an SDK request + * too, and — the point of naming the URL in the error — says *which* endpoint + * stalled instead of blaming the handshake. + * + * ## The signal, the proxy hop, and why the race is still here + * + * The signal is the primary mechanism and it now reaches all the way out. On a + * direct fetch (CLI, TUI, backend) it cancels the request outright. In the + * browser the OAuth fetch is `createRemoteFetch`, which re-issues the call as a + * POST to `/api/fetch` — and as of #2319 it forwards the signal onto that hop, + * where the route composes `c.req.raw.signal` into its own outbound fetch. So + * an abort here tears down the backend's request to the authorization server + * rather than leaving it running detached, which is what it used to do. + * + * The race is kept anyway, and it is not redundant. It bounds what the signal + * cannot: a `fetchFn` that ignores `AbortSignal` altogether — an injected or + * test double, or any future transport that drops it the way the proxy used to + * — and a backend that is itself wedged, where the outbound request is + * cancelled but the hop back never completes. A deadline that depends on every + * layer beneath it honouring a signal is not a deadline. Same reasoning, same + * shape, as `revokeToken`. + */ + +import { redactUrlQuery } from "../mcp/fetchTracking.js"; + +/** + * 30 seconds. Deliberately generous: discovery against a slow or cold-starting + * authorization server is legitimate, and a bound that fires on a server that + * was going to answer is worse than the unbounded wait it replaced. It still + * halves the 60s the SDK handshake incidentally provides, and it is the only + * bound at all on the legs that sit outside an SDK request. + */ +export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; + +/** + * Raised when an OAuth-path request outlives its budget. + * + * Carries the URL because "discovery timed out" without one is only marginally + * better than a spinner: the OAuth path makes several requests to several + * hosts, and which of them stalled is the whole diagnostic. + */ +export class OAuthRequestTimeoutError extends Error { + /** + * The endpoint that stalled, with sensitive query values redacted. + * + * Redacted in the constructor rather than at each display site, so every + * consumer is covered by construction: this error's message is recorded + * verbatim by `createFetchTracker` into the Network log and the persisted + * session, and an OAuth endpoint can carry a `code`, an `access_token` or a + * `client_secret` in its query string — which `fetchTracking` already + * deliberately redacts everywhere else it records a URL (Copilot). The path + * and every non-sensitive parameter survive, so the endpoint stays + * identifiable, which was the point of naming it. + */ + readonly url: string; + readonly timeoutMs: number; + + constructor(url: string, timeoutMs: number) { + const safeUrl = redactUrlQuery(url); + super(`OAuth request to ${safeUrl} timed out after ${timeoutMs}ms`); + this.name = "OAuthRequestTimeoutError"; + this.url = safeUrl; + this.timeoutMs = timeoutMs; + } +} + +/** + * The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the + * delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an + * `Infinity` or an over-large budget produces an *immediate* timeout — the exact + * opposite of what the caller asked for (Copilot). + */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** + * Coerce a caller's budget into something `setTimeout` can honour and the error + * can honestly report. + * + * - Non-finite (`NaN`, `±Infinity`) falls back to the default. `NaN` survives + * `Math.max(0, Math.round(...))` and `setTimeout(fn, NaN)` fires immediately, + * so a caller whose arithmetic produced a `NaN` would otherwise see every + * OAuth request fail at once with "timed out after NaNms". + * - Above `MAX_TIMER_DELAY_MS` it clamps rather than overflowing to 1ms. + * - Negative clamps to 0, which is a real budget (fire on the next tick), not + * an error. + * + * Rounded because the budget is *reported* — see the note at the call site. + */ +function normalizeBudget(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs)) return DEFAULT_OAUTH_REQUEST_TIMEOUT_MS; + return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, Math.round(timeoutMs))); +} + +/** + * How a proxy-side deadline is carried back to the browser (#2319). + * + * `/api/fetch` serializes its failures as a JSON error response, and + * `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a + * timeout enforced by the *backend* would arrive as an untyped error and the + * `instanceof OAuthRequestTimeoutError` checks downstream would all be false + * (Copilot). Both ends run the same budget, so the client's race usually + * settles first; this matters when the backend's timer wins anyway — most + * plausibly a backgrounded tab, where the browser throttles `setTimeout` while + * the server's fires on schedule. Which of two equal deadlines happened to fire + * must not decide whether the error names its endpoint. So the route stamps + * this marker and `createRemoteFetch` reconstructs the typed error from it. + */ +export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout"; + +/** The body `/api/fetch` returns when its own deadline fired. */ +export interface OAuthRequestTimeoutWire { + code: typeof OAUTH_TIMEOUT_WIRE_CODE; + url: string; + timeoutMs: number; +} + +/** Whether a parsed `/api/fetch` error body is a proxy-side deadline. */ +export function isOAuthRequestTimeoutWire( + value: unknown, +): value is OAuthRequestTimeoutWire { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + candidate.code === OAUTH_TIMEOUT_WIRE_CODE && + typeof candidate.url === "string" && + typeof candidate.timeoutMs === "number" + ); +} + +/** + * Build the `isExempt` predicate for a chain that carries MCP traffic as well as + * OAuth work: it returns true for the MCP endpoint itself. + * + * ⚠️ **Fails open, deliberately.** The two errors are not symmetric. Bounding a + * request that should not be bounded severs a long-running tool call or an SSE + * stream — a severe, user-visible regression. Failing to bound one leaves the + * SDK's own per-request timeout as the backstop it has always been. So anything + * uncertain — no server URL yet, a URL that will not parse, either side — is + * treated as the MCP endpoint and exempted. + * + * The comparison is **as narrow as the transport allows**, which is not the same + * rule for both of them: + * + * - **Streamable HTTP** sends every MCP request to the configured URL, so + * `origin + pathname` is exact. Same-origin OAuth work — a protected-resource + * document, or an authorization server deployed beside the resource — is a + * different path and stays bounded, which is what #2319 asks for (Copilot). + * - **Legacy SSE** opens the configured URL (`/sse`), is handed a *separate* + * message endpoint by the server, and POSTs every JSON-RPC message to that + * second pathname. We never see that URL — it arrives inside the stream, in + * the SDK's own parser — so a path rule there bounds and body-buffers real + * MCP traffic, severing a slow tool call at 30s and reporting it as an + * `OAuthRequestTimeoutError`. Origin is the narrowest rule available, and it + * is exact rather than approximate: the SDK *enforces* that the message + * endpoint shares the stream URL's origin and rejects one that does not. + * + * So the residual gap is confined to legacy SSE against a server that also + * hosts its own OAuth endpoints, where transport-internal OAuth falls back to + * the SDK's per-request timeout — where it was before #2319. The Inspector's + * own discovery and token requests to those URLs are bounded regardless, on the + * OAuth chain. + * + * ⚠️ **Fails open** on every uncertainty, and the uncertainties differ by rule: + * no server URL, an unparseable URL on either side, or *not knowing which + * transport this is* all resolve to exempt — the last one by falling back to + * the wider origin rule. + */ +/** + * Read the transport hint, treating "absent" and "threw" alike as the wide + * case. A path rule applied to a connection that turns out to be legacy SSE + * severs real MCP traffic, so every uncertainty resolves toward the origin rule. + */ +function readNegotiatesEndpoint(hint: (() => boolean) | undefined): boolean { + if (!hint) return true; + try { + return hint(); + } catch { + return true; + } +} + +/** + * The media type every OAuth token-family request uses — the token exchange, + * the refresh, and revocation are all `application/x-www-form-urlencoded` per + * RFC 6749 §4.1.3 and RFC 7009 §2.1. MCP is JSON-RPC over `application/json` + * and never uses it, which is what makes this a safe discriminator rather than + * a heuristic. + */ +const OAUTH_FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"; + +export function exemptMcpEndpoint( + getServerUrl: () => string | undefined, + /** + * Whether the message endpoint is negotiated rather than configured — true + * for legacy SSE. Read per call, since the transport can change between + * connects. Absent, or throwing, is treated as `true`. + */ + negotiatesEndpoint?: () => boolean, +): (url: string, headers: Headers) => boolean { + return (url, headers) => { + // A URL match is not by itself proof of MCP traffic. `oauthTokenUrl` takes + // any absolute URL, so a user may point it at the MCP endpoint's own URL, + // and the transport-internal refresh then travels this chain to a path the + // rules below would exempt — leaving the one request this whole change + // exists to bound running unbounded (Copilot). The token family is + // form-encoded and MCP never is, so the media type settles it before any + // URL comparison happens. + if ( + (headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() === + OAUTH_FORM_MEDIA_TYPE + ) { + return false; + } + + // `getServerUrl()` throws for a non-HTTP configuration, and this wrapped + // fetch can be handed to a transport or a custom factory that calls it + // anyway — propagating a configuration error out of a fetch would turn the + // documented fail-open into a hard failure (Copilot). Caught here for the + // same reason `readNegotiatesEndpoint` catches. + let serverUrl: string | undefined; + try { + serverUrl = getServerUrl(); + } catch { + return true; + } + if (!serverUrl) return true; + const negotiated = readNegotiatesEndpoint(negotiatesEndpoint); + try { + const a = new URL(url); + const b = new URL(serverUrl); + if (a.origin !== b.origin) return false; + return negotiated || a.pathname === b.pathname; + } catch { + return true; + } + }; +} + +/** + * The deadline a wrapped call is running under, keyed by the exact `RequestInit` + * the wrapper handed down (#2319). + * + * This is how the budget crosses the proxy hop without travelling *upstream*. + * `/api/fetch` must not time every request it serves — `createRemoteFetch` also + * carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold + * its response headers for minutes — so the route needs to know which requests + * are bounded and which are not (Copilot). A header would be the obvious + * carrier and is the wrong one: `serializeRequest` copies request headers + * verbatim into the payload and the route re-sends them to the authorization + * server, so a marker header would leak to a third party. + * + * A `WeakMap` keyed on the init object has neither problem. It is invisible to + * serialization, it needs no cast onto `RequestInit`, and it is collected with + * the object — the wrapper builds a fresh init per call, so there is one entry + * per in-flight request and nothing accumulates. + */ +const REQUEST_DEADLINES = new WeakMap(); + +/** + * The deadline `withOAuthRequestTimeout` stamped on this init, if any. + * + * `undefined` means the request is not bounded by this wrapper and must not be + * bounded by anything downstream either — an exempt MCP request reads as + * `undefined` because the exempt path never builds an init of its own. + */ +export function deadlineForRequestInit( + init: RequestInit | undefined, +): number | undefined { + return init ? REQUEST_DEADLINES.get(init) : undefined; +} + +/** + * The request's headers, whatever form `fetch`'s arguments took. `init` wins + * over a `Request`'s own, matching `fetch`. + */ +function requestHeadersOf( + input: RequestInfo | URL, + init: RequestInit | undefined, +): Headers { + if (init?.headers) return new Headers(init.headers); + if (typeof input !== "string" && !(input instanceof URL)) { + return new Headers(input.headers); + } + return new Headers(); +} + +/** + * Pull a reader to completion, discarding what it yields. An absent reader — a + * null-body response — is already complete. + * + * Read through an explicit reader rather than `clone().arrayBuffer()` so the + * losing side of the race has something it can cancel: `arrayBuffer()` locks + * the stream to an internal reader nothing else can reach. + */ +async function drainToEnd( + reader: ReadableStreamDefaultReader | undefined, +): Promise { + if (!reader) return; + for (;;) { + const { done } = await reader.read(); + if (done) return; + } +} + +/** + * Tear down both branches of the tee. + * + * Deliberately **not awaited**, and the `void`s say which of the documented + * cases this is: the callee owns its failures (each carries its own `catch`), + * and the caller genuinely cannot await — cancelling one branch of a tee can + * itself wait on the other, which would hold the timeout open for exactly as + * long as the stall it is meant to end. Best-effort on each besides: a stream + * already cancelled, errored or locked elsewhere is not a failure here, and + * this runs on a path that is already rejecting with something the caller cares + * about more. + */ +function cancelBoth( + reader: ReadableStreamDefaultReader | undefined, + response: Response | undefined, +): void { + void reader?.cancel().catch(() => {}); + void response?.body?.cancel().catch(() => {}); +} + +/** The request URL, whatever form `fetch`'s first argument took. */ +function requestUrlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** + * The caller's own `AbortSignal`, if it supplied one. + * + * A `Request` carries its signal on the *input*, not in `init`, and this + * wrapper always passes a `signal` of its own in `init` — so reading `init` + * alone would silently override the embedded one and change ordinary `fetch` + * cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))` + * (Copilot). + * + * An `init.signal` still wins, but only when it is not `undefined`. `RequestInit` + * is a WebIDL dictionary, where a member present with the value `undefined` is + * converted as *absent* — so `{ ...base, signal: undefined }`, which is what a + * spread over an options object that had no signal produces, must inherit the + * `Request`'s signal rather than clear it (Copilot). An explicit `null` is the + * genuine "no signal" override and is honoured as one. + */ +export function callerSignalOf( + input: RequestInfo | URL, + init: RequestInit | undefined, +): AbortSignal | undefined { + const explicit = init?.signal; + if (explicit !== undefined) return explicit ?? undefined; + if (typeof input !== "string" && !(input instanceof URL)) return input.signal; + return undefined; +} + +/** + * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. + * + * Every request that reaches it unexempted is bounded — so on a chain that + * carries anything other than OAuth work, `isExempt` is not optional. A + * Streamable HTTP or SSE response is a long-lived stream that is *supposed* to + * stay open, and a Streamable HTTP POST for a long-running tool call + * legitimately withholds its response headers for minutes; a deadline over + * either severs a working connection. `InspectorClient` uses the wrapper on + * both of its chains and the two differ only in this: the OAuth chain passes no + * predicate because every request on it is OAuth work, while the transport + * chain passes `exemptMcpEndpoint`. + * + * `isExempt` receives the request's URL **and headers**, because a URL alone + * cannot always tell the two kinds of traffic apart — see `exemptMcpEndpoint`. + * + * `isExempt` lets one wrapped fetch serve a mixed chain. It is how the + * *transport* fetch can be bounded at all: that chain carries both the SDK's + * OAuth work and the MCP traffic itself, and the MCP traffic must not be + * timed — a Streamable HTTP POST for a long-running tool call legitimately + * withholds its response headers for minutes, and an SSE body stays open + * indefinitely. So `InspectorClient` exempts the MCP endpoint by URL and bounds + * everything else on that chain, and **fails open**: if the server URL cannot + * be determined or parsed, everything is exempt. Bounding a request that should + * not be bounded breaks a working tool call; failing to bound one leaves the + * SDK's own per-request timeout as the backstop it already was. + * + * A caller's own signal is preserved — whether it arrived in `init` or embedded + * in a `Request` — and is both forwarded to the inner fetch (composed with + * `AbortSignal.any`) and raced here, so an outer cancellation still wins even on + * a fetch that drops the signal. This wrapper only ever *adds* a reason to give + * up; it never removes the caller's. + * + * The response **body** is drained through a clone under the same deadline, + * because `fetch` resolves on headers and a stalled body would otherwise be + * unbounded. The caller receives the original response, not a copy. + */ +export function withOAuthRequestTimeout( + fetchFn: typeof fetch, + timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + isExempt?: (url: string, headers: Headers) => boolean, +): typeof fetch { + // Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one + // calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on + // a fractional delay (verified on Node 26) and so would fail before the + // request was sent. This wrapper drives its deadline with `setTimeout` and an + // `AbortController`, and `setTimeout` accepts a fractional delay and + // truncates it, so nothing here would throw (Copilot). + // + // The real reason is that the budget is *reported*: it is interpolated into + // the timeout message and exposed as `OAuthRequestTimeoutError.timeoutMs`. A + // caller whose budget came from a `performance.now()` subtraction would + // otherwise produce "timed out after 1000.4000000953674ms" and a non-integer + // public field. Rounding rather than flooring so a caller's own + // whole-millisecond timeout survives the trip through that clock and is still + // the number the message names. + const budget = normalizeBudget(timeoutMs); + + return async (input, init) => { + const url = requestUrlOf(input); + // An exempt request is passed through completely untouched — no deadline, + // no signal of ours, and crucially no body buffering, since the responses + // this exists for are streams that must stay open (see `isExempt` above). + if (isExempt?.(url, requestHeadersOf(input, init))) { + return fetchFn(input, init); + } + + const controller = new AbortController(); + const callerSignal = callerSignalOf(input, init); + // Before anything is constructed or sent. Racing an already-aborted signal + // would still evaluate `fetchFn(...)`, and a `fetchFn` that does not check + // an already-aborted signal — the proxy hop was one until #2319 — would + // send the OAuth request even though the caller cancelled before the call + // (Copilot). + if (callerSignal?.aborted) throw callerSignal.reason; + + const signal = callerSignal + ? AbortSignal.any([controller.signal, callerSignal]) + : controller.signal; + + let timer: ReturnType | undefined; + let timedOut = false; + let onCallerAbort: (() => void) | undefined; + + // The losing side of every race below: it rejects when the budget runs out, + // and when the caller cancels. + const abandoned = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + // Abort first, which is what actually cancels the request — through + // the proxy hop too, since #2319 — then reject, so a `fetchFn` that + // ignores the signal is still abandoned on schedule. + controller.abort(new OAuthRequestTimeoutError(url, budget)); + reject(new OAuthRequestTimeoutError(url, budget)); + }, budget); + + // The caller's cancellation is raced too, not merely forwarded. Forwarding + // it is enough wherever the signal is honoured, but a `fetchFn` that + // ignores it would otherwise leave this promise pending for the whole + // budget instead of the outer cancellation winning as documented + // (Copilot). The caller's own `reason` is preserved, so it still sees its + // abort rather than a substituted error. + // Not aborted — the short-circuit above returned for that case. + if (callerSignal) { + onCallerAbort = () => reject(callerSignal.reason); + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); + } + }); + + // Built once and stamped, so `createRemoteFetch` can read the budget off it + // and tell `/api/fetch` this particular request is bounded. + const nextInit: RequestInit = { ...init, signal }; + REQUEST_DEADLINES.set(nextInit, budget); + + // Held so the losing path can tear both tee branches down — see below. + let settled: Response | undefined; + let drainReader: ReadableStreamDefaultReader | undefined; + + try { + const response = await Promise.race([ + fetchFn(input, nextInit), + abandoned, + ]); + settled = response; + // `fetch` resolves once the response *headers* arrive, so stopping here + // would leave the body unbounded: an authorization server can send + // headers, or half a JSON document, and then stall, and the caller's + // `response.json()` would hang with nothing watching it (Copilot). Every + // response on this path is a small, finite document — metadata, + // a registration, a token — so buffering it under the same deadline + // bounds the whole exchange. It is also the second reason `isExempt` is + // not optional on a mixed chain: an exempt request skips this buffering + // entirely, which is what lets an SSE body stay open rather than being + // drained into memory here. + // Read a *clone* under the deadline and hand back the original. Cloning + // tees the body, so draining one branch buffers the other: by the time + // this resolves the original is fully in memory and the caller reads it + // without touching the network again — the bound is the same, and the + // response the caller gets is the one `fetch` produced. + // + // The alternative, rebuilding a `Response` around the buffered bytes, + // could never be faithful (Copilot). `url`, `redirected` and `type` are + // internal slots; shadowing them as own properties satisfies a direct + // read and is lost again the moment a caller calls `clone()`, which + // returns a fresh native `Response`. The headers guard differs too, and + // the decoded bytes made the inherited `content-encoding` and + // `content-length` wrong, so those had to be stripped — a rebuilt + // response was observably not the original in at least four ways. Not + // rebuilding removes the whole class. + const copy = response.clone(); + drainReader = copy.body?.getReader(); + await Promise.race([drainToEnd(drainReader), abandoned]); + return response; + } catch (err) { + // ⚠️ Losing the race rejects the caller; on its own it does not stop the + // read. `clone()` tees the body, so a drain still running would keep + // pulling bytes *and* keep buffering them for the unread original branch + // — unbounded memory on exactly the signal-ignoring body the race exists + // to contain (Copilot). Both branches are cancelled here, which is safe + // precisely because this path throws: the caller never receives the + // response, so nothing is left to read it. + cancelBoth(drainReader, settled); + // `controller.abort()` above can reject the underlying fetch *before* + // `reject` runs, in which case the race settles with undici's + // `AbortError` instead of ours. Which of the two wins is an ordering + // detail of the fetch implementation, so normalize on the flag rather + // than on the error that surfaced: a timeout must always be reported as + // one, naming the endpoint. A caller-driven abort leaves the flag false + // and so passes through with its own reason intact. + if (timedOut) throw new OAuthRequestTimeoutError(url, budget); + throw err; + } finally { + /* v8 ignore next -- the Promise executor runs synchronously, so `timer` + is always assigned by the time this runs; the guard exists only because + TypeScript cannot see that. */ + if (timer !== undefined) clearTimeout(timer); + // Drop the listener even though it is `once`: a caller signal can outlive + // this request (one `AbortController` per connect attempt, several + // requests through it), and a listener per request would accumulate on it. + if (onCallerAbort && callerSignal) { + callerSignal.removeEventListener("abort", onCallerAbort); + } + } + }; +} diff --git a/core/auth/revocation.ts b/core/auth/revocation.ts index f2d16c0f..1ffc8e6b 100644 --- a/core/auth/revocation.ts +++ b/core/auth/revocation.ts @@ -268,14 +268,15 @@ export async function revokeToken( // outcome — and every caller has already cleared its local state by now, so // a rejection would break the documented best-effort guarantee. const { url, init } = buildRevocationRequest(params); - // The signal alone is not enough to bound this. In the browser the fetch is - // `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch` - // and does not forward `init.signal`; the backend's outbound fetch gets no - // signal either. So a wedged authorization server would hold the teardown - // open indefinitely on exactly the path the timeout exists for. The race is - // what actually enforces the deadline; the signal is kept because it does - // cancel the direct-fetch paths (CLI, TUI, backend) rather than merely - // abandoning them. + // The signal cancels; the race is what guarantees the deadline. Since #2319 + // the signal reaches every path — `createRemoteFetch` forwards it onto the + // `/api/fetch` hop and that route composes it into its outbound fetch, so a + // browser-side abort now tears down the backend's request to the + // authorization server as well as the direct-fetch paths (CLI, TUI, + // backend) it always did. The race is still not redundant: it bounds a + // `fetchFn` that ignores `AbortSignal` altogether, and a backend that is + // itself wedged. Same reasoning, and the same shape, as + // `withOAuthRequestTimeout`. const response = await withDeadline( params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs, @@ -307,11 +308,12 @@ export async function revokeToken( /** * Reject with a timeout error if `promise` has not settled within `timeoutMs`. * - * The underlying request is abandoned rather than cancelled — nothing here can - * cancel a fetch the proxy already stripped the signal from. That is the right - * trade for this caller: the point is that the *teardown* proceeds, and the - * response, if it ever arrives, is a revocation we no longer need to wait for. - * The timer is cleared on the settled path so a caller is never held awake by it. + * This abandons the promise; cancelling the request is the caller's signal's job + * (see `revokeToken`, where the two are used together). Abandonment alone is an + * acceptable floor for this caller even where the signal is ignored: the point + * is that the *teardown* proceeds, and the response, if it ever arrives, is a + * revocation we no longer need to wait for. The timer is cleared on the settled + * path so a caller is never held awake by it. */ async function withDeadline( promise: Promise, diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index 82c66f1f..48f762d4 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -58,9 +58,63 @@ const HttpUrlStringSchema = z export const CIMD_METADATA_URL_INVALID_ERROR = "Must be a valid URL, like https://example.com/oauth/client.json"; -/** Field-level error when a CIMD metadata URL is not HTTPS. */ +/** + * Field-level error when a CIMD metadata URL is neither HTTPS nor served from + * one of the exempt loopback literals. The message names the exemption rather + * than saying only "must use HTTPS", so a developer pointing the field at a + * local fixture is told which spellings work instead of concluding that nothing + * local can (the same phrasing lesson as the token-endpoint notice in + * `core/auth/oauthUx.ts`). + */ export const CIMD_METADATA_URL_HTTPS_ERROR = - "CIMD client metadata URL must use HTTPS"; + "CIMD client metadata URL must use HTTPS, except on localhost, 127.0.0.1 or [::1]"; + +/** + * Hosts for which a plain `http:` CIMD metadata URL is accepted. + * + * SEP-991 expects HTTPS in production for a real reason: a CIMD `client_id` is + * a URL the authorization server dereferences, and over plain HTTP the document + * it gets back is attacker-modifiable in transit. Loopback is the documented + * exception to exactly that reasoning — there is no network segment to sit on — + * which is why the SDK exempts these same three literals from its token-endpoint + * TLS assertion, and why `core/auth/cimd.ts` already notes that an + * already-stored `client_id` may be an `http://` URL "used by local dev/test + * metadata servers". Without this, the runtime tolerated a value the config + * validation would not let anyone enter, and CIMD could not be driven against + * any fixture in this repo (#2305). + * + * ⚠️ This is the SDK's own list, matched member for member — not the broader + * `isLoopbackHost` in `core/node/hostUrl.ts`, which imports `node:net` and so + * cannot be reached from this browser-safe module. The SDK's + * `assertSecureTokenEndpoint` tests `url.hostname` against exactly + * `localhost` / `127.0.0.1` / `[::1]` (plus a `::1` arm `URL.hostname` never + * produces, since it brackets IPv6 literals). Keeping the two identical is what + * stops them disagreeing about one URL. + * + * ⚠️ **The comparison is against the WHATWG-canonicalized `URL.hostname`, so it + * covers alternate spellings of these three *addresses*, not three input + * strings.** `http://127.1/…`, `http://2130706433/…`, `http://0x7f.0.0.1/…` and + * `http://[0:0:0:0:0:0:0:1]/…` all canonicalize into the set and are accepted; + * so is a root-anchored `http://127.0.0.1./…`, which WHATWG strips the dot from + * for IP literals but *not* for `localhost.`. That is deliberate on both counts: + * the security argument is about the address the request actually reaches, and + * every one of these is the loopback interface. It is also exactly what the SDK + * does — it canonicalizes through `new URL` the same way — so an alias accepted + * here is an alias the SDK accepts too. + */ +const CIMD_HTTP_EXEMPT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * True when a plain-`http:` CIMD metadata URL on this host is acceptable. Takes + * the already-canonicalized `URL.hostname`, never a raw authority or a + * `host:port` — passing an unparsed string would compare the wrong thing and + * silently reject every alias above. Not exported: the only caller is the + * validator below, and the exemption is exercised through it rather than + * through a second public surface that could drift from it. + */ +function isCimdHttpExemptHost(hostname: string): boolean { + return CIMD_HTTP_EXEMPT_HOSTS.has(hostname); +} /** Field-level error when a CIMD metadata URL has no path segment. */ export const CIMD_METADATA_URL_PATH_ERROR = @@ -81,7 +135,7 @@ export function getCimdClientMetadataUrlError( } try { const url = new URL(trimmed); - if (url.protocol !== "https:") { + if (url.protocol !== "https:" && !isCimdHttpExemptHost(url.hostname)) { return CIMD_METADATA_URL_HTTPS_ERROR; } if (url.pathname === "/" || url.pathname === "") { diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index bf77d19c..41bcfcc1 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -40,6 +40,7 @@ import type { } from "../types.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js"; import type { MalformedListItem } from "../listSalvage.js"; +import type { ConnectionDiagnostics } from "../connectionDiagnostics.js"; import type { SkillEntry, SkillResource } from "../skillsSchemas.js"; import type { SkillsExtensionSupport } from "../skills.js"; import type { JsonValue } from "../../json/jsonUtils.js"; @@ -321,6 +322,21 @@ export class FakeInspectorClient return this.discoverResult; } + private connectionDiagnostics: ConnectionDiagnostics = { + capturedAt: 0, + outstandingRequests: [], + }; + + getConnectionDiagnostics(): ConnectionDiagnostics { + return this.connectionDiagnostics; + } + + /** Test helper: set the diagnostics snapshot and emit the change (#2318). */ + setConnectionDiagnostics(diagnostics: ConnectionDiagnostics): void { + this.connectionDiagnostics = diagnostics; + this.dispatchTypedEvent("connectionDiagnosticsChange", diagnostics); + } + resourceSubscriptionStreamState: ResourceSubscriptionStreamState = INACTIVE_SUBSCRIPTION_STREAM_STATE; diff --git a/core/mcp/appElicitation.ts b/core/mcp/appElicitation.ts index 1ea84651..b15039e7 100644 --- a/core/mcp/appElicitation.ts +++ b/core/mcp/appElicitation.ts @@ -21,8 +21,9 @@ import { MCP_APP_MIME_TYPE, UI_EXTENSION_KEY } from "./extensions.js"; * * The helpers here mirror the ext-apps draft (modelcontextprotocol/ext-apps#733, * SEP-3118) so the Inspector speaks exactly the proposed protocol. They are - * declared locally only because the released `@modelcontextprotocol/ext-apps` - * (1.7.5) predates that PR and exports none of them; replace + * declared locally only because #733 is not yet in a published release — the + * installed `@modelcontextprotocol/ext-apps` (2.0.0) does not include it and + * exports none of them; replace * {@link supportsAppElicitation} / {@link getElicitationUiResourceUri} with the * package's `/server` exports once a release containing #733 ships. */ diff --git a/core/mcp/apps.ts b/core/mcp/apps.ts index 366e5a21..024cd490 100644 --- a/core/mcp/apps.ts +++ b/core/mcp/apps.ts @@ -1,4 +1,11 @@ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + McpUiResourceCsp, + McpUiResourceMeta, + McpUiResourcePermissions, + McpUiToolMeta, + McpUiToolVisibility, +} from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ReadResourceResult, Tool } from "@modelcontextprotocol/client"; /** @@ -19,13 +26,7 @@ import type { ReadResourceResult, Tool } from "@modelcontextprotocol/client"; * `@inspector/core`. */ export function getAppResourceUri(tool: Tool): string | undefined { - // `@modelcontextprotocol/ext-apps` still peers on SDK v1, so its - // `getToolUiResourceUri` param is typed as the v1 `Tool`. The runtime shape - // this reads (`_meta.ui.resourceUri`) is identical under v2, so cast at this - // single boundary. TODO: drop the cast when ext-apps#702 ships a v2 peer. - return getToolUiResourceUri( - tool as Parameters[0], - ); + return getToolUiResourceUri(tool); } /** @@ -53,10 +54,10 @@ export interface AppInfo { hasApp: boolean; toolName: string; resourceUri?: string; - visibility?: readonly string[]; + visibility?: readonly McpUiToolVisibility[]; /** From the UI resource's `_meta.ui` (per spec, csp/permissions/domain live on the resource, not the tool). Absent when the resource was not read. */ - csp?: Readonly>; - permissions?: Readonly>; + csp?: Readonly; + permissions?: Readonly; domain?: string; prefersBorder?: boolean; resourceMimeType?: string; @@ -65,37 +66,26 @@ export interface AppInfo { type WithUiMeta = { _meta?: { ui?: unknown } }; /** - * Structural shape for `_meta.ui` carriers — the ext-apps package's named - * types (`McpUiToolMeta`, `McpUiResourceMeta`) are not importable under - * NodeNext module resolution because of an extensionless re-export in the - * package's `.d.ts`, so we read the fields structurally instead. The values - * pass through verbatim into {@link AppInfo}; callers can narrow them against - * the published Zod schemas if they need strict typing. + * Reads a carrier's `_meta.ui` as one of ext-apps' named `_meta.ui` shapes — + * `McpUiToolMeta` for a tool, `McpUiResourceMeta` for a UI resource — narrowing + * from `unknown` so call sites don't need their own casts. Returns `undefined` + * unless both the carrier and its `_meta.ui` are non-null objects. * - * TODO: switch to the named `McpUiToolMeta` / `McpUiResourceMeta` types once - * upstream fixes the extensionless re-export so they resolve under NodeNext. + * The narrowing is structural, not validated: the fields pass through verbatim + * into {@link AppInfo}, and a caller that needs strict typing can parse them + * against the published Zod schemas (`McpUiToolMetaSchema` / + * `McpUiResourceMetaSchema`). Typed with the package's own names since ext-apps + * 2.0.0, whose declarations resolve under NodeNext (ext-apps#705) — the + * structural stand-in that preceded it is gone (#1745). */ -interface UiMetaShape { - visibility?: readonly string[]; - csp?: Record; - permissions?: Record; - domain?: string; - prefersBorder?: boolean; -} - -/** - * Reads a carrier's `_meta.ui` structurally, narrowing from `unknown` so call - * sites don't need their own casts. Returns `undefined` unless both the carrier - * and its `_meta.ui` are non-null objects. - */ -function readUiMeta(carrier: unknown): UiMetaShape | undefined { +function readUiMeta( + carrier: unknown, +): T | undefined { const ui = carrier !== null && typeof carrier === "object" ? (carrier as WithUiMeta)._meta?.ui : undefined; - return ui !== null && typeof ui === "object" - ? (ui as UiMetaShape) - : undefined; + return ui !== null && typeof ui === "object" ? (ui as T) : undefined; } /** @@ -149,14 +139,16 @@ export function extractAppInfo( if (resourceUri === undefined) { return { hasApp: false, toolName: tool.name }; } - const toolUi = readUiMeta(tool); + const toolUi = readUiMeta(tool); const content = resource && findResourceContent(resource, resourceUri); // Precedence is intentional: prefer the matched content block's `_meta.ui`, // falling back to the result-level `_meta.ui`. Per the current spec the // security posture (csp/permissions/domain) lives on the content block, so we // don't shallow-merge the two carriers — a content block that declares any // `ui` is treated as authoritative for all of its fields. - const resourceUi = readUiMeta(content) ?? readUiMeta(resource); + const resourceUi = + readUiMeta(content) ?? + readUiMeta(resource); return { hasApp: true, toolName: tool.name, diff --git a/core/mcp/connectionDiagnostics.ts b/core/mcp/connectionDiagnostics.ts new file mode 100644 index 00000000..9163f557 --- /dev/null +++ b/core/mcp/connectionDiagnostics.ts @@ -0,0 +1,252 @@ +/** + * Connection diagnostics: what the client is still waiting on, when it last + * heard back, and the state of the server-to-client notification stream — and + * the one place that turns the SDK's bare `Request timed out` into a message + * that reports those facts (#2318). + * + * The motivating case is #2187: a server that serves one request at a time. + * The standalone `GET /mcp` stream the client opens after `initialize` + * occupies that single slot, so `initialize` succeeds and every request after + * it hangs — and the Inspector's only word on it was `Request timed out`, + * which names the symptom the user can already see. Every fact needed to + * suspect the cause was already in-process at that moment: the stream had been + * open for the whole session and delivered nothing, `tools/list` had been + * outstanding for 60s, and nothing had been answered since `initialize`. + * + * Everything here is pure. `InspectorClient` owns the bookkeeping (it sees + * every outbound request, every inbound response and every tracked fetch) and + * hands a snapshot to {@link annotateRequestTimeout}; the UI clients render the + * same snapshot through `getConnectionDiagnostics()`. + * + * Deliberately factual: the summary reports what the connection's state *is* + * and lets the reader draw the conclusion. It does not assert a diagnosis the + * Inspector cannot verify from its own side. + */ + +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; + +/** A request this client sent that no response has arrived for. */ +export interface OutstandingRequest { + id: string | number; + method: string; + /** Epoch ms at which the request went out. */ + sentAt: number; +} + +/** The most recent response received to one of this client's requests. */ +export interface LastResponse { + /** The method of the request it answered. */ + method: string; + /** Epoch ms at which it arrived. */ + receivedAt: number; +} + +/** + * The most recent long-lived server-to-client stream the transport opened — + * the standalone `GET` on Streamable HTTP, or the primary event stream on + * legacy SSE. Absent on stdio, and until the first such stream is opened. + */ +export interface NotificationStreamState { + /** The request URL, as recorded (query already redacted). */ + url: string; + /** Epoch ms at which the response headers arrived and the stream opened. */ + openedAt: number; + /** SSE events delivered so far — messages, not keepalive comments. */ + eventCount: number; + /** Epoch ms at which the stream ended, once it has. */ + closedAt?: number; +} + +export interface ConnectionDiagnostics { + /** + * Epoch ms at which this snapshot was taken. Every duration derived from + * the snapshot ("sent 60s ago", "open for 4m12s") is measured against this + * clock rather than against the moment of rendering, so the snapshot is + * self-contained: a message written from it, a component rendering it and + * a test asserting on it all read the same numbers. + */ + capturedAt: number; + /** + * Every request sent this session that has received no response, oldest + * first. A request the SDK gave up on (a timeout, a cancel) stays here: it + * is still unanswered, which is the fact being reported. + */ + outstandingRequests: OutstandingRequest[]; + lastResponse?: LastResponse; + notificationStream?: NotificationStreamState; +} + +/** + * The data the SDK attaches to a per-request timeout: `{ timeout }` in ms. + * Narrowed here rather than imported because the SDK types it `unknown`. + */ +interface RequestTimeoutData { + timeout: number; +} + +function isRequestTimeoutData(value: unknown): value is RequestTimeoutData { + return ( + typeof value === "object" && + value !== null && + typeof (value as { timeout?: unknown }).timeout === "number" + ); +} + +/** + * Whether `err` is the SDK's per-request timeout: an `SdkError` with + * `RequestTimeout` and a numeric `timeout` in its data. + * + * The data check is not decoration. `Protocol.request` reuses the + * `RequestTimeout` code for two other rejections that are not timeouts at + * all — an abort through `options.signal` whose reason is not an `SdkError` + * (the Cancel button), and "Maximum total timeout exceeded" — and neither + * carries `{ timeout }`. Only the genuine per-request timeout does. + */ +export function isRequestTimeoutError( + err: unknown, +): err is SdkError & { data: RequestTimeoutData } { + return ( + SdkError.isInstance(err) && + err.code === SdkErrorCode.RequestTimeout && + isRequestTimeoutData(err.data) + ); +} + +/** + * A compact human duration: `850ms`, `12s`, `4m12s`, `1h02m`. Sub-second + * values are shown in ms so a very short timeout still reads as a number + * rather than as `0s`. + */ +export function formatDuration(ms: number): string { + const clamped = Math.max(0, ms); + if (clamped < 1000) return `${Math.round(clamped)}ms`; + const totalSeconds = Math.round(clamped / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +/** + * How many outstanding requests the summary names individually before + * collapsing the rest into "and N more". Enough to show the shape of a stall + * (the one everything is queued behind, plus what queued behind it) without + * turning a toast into a page. + */ +export const MAX_LISTED_OUTSTANDING_REQUESTS = 5; + +/** + * `GET /mcp` for a stream URL: method plus path only. The origin is the + * server the user is already connected to, and the query string (already + * redacted at tracking time) adds nothing to a one-line summary. Falls back to + * the raw string for an unparseable URL rather than dropping the clause. + */ +export function describeStreamEndpoint(url: string): string { + try { + return `GET ${new URL(url).pathname}`; + } catch { + return `GET ${url}`; + } +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function describeOutstanding( + requests: OutstandingRequest[], + now: number, +): string { + if (requests.length === 0) return "No requests are unanswered."; + const listed = requests + .slice(0, MAX_LISTED_OUTSTANDING_REQUESTS) + .map((r) => `${r.method} (sent ${formatDuration(now - r.sentAt)} ago)`); + const rest = requests.length - listed.length; + const tail = rest > 0 ? `, and ${rest} more` : ""; + const verb = requests.length === 1 ? "is" : "are"; + return `${plural(requests.length, "request")} ${verb} unanswered: ${listed.join(", ")}${tail}.`; +} + +function describeLastResponse( + lastResponse: LastResponse | undefined, + now: number, +): string { + if (!lastResponse) return "No response has been received this session."; + return `Last response received ${formatDuration(now - lastResponse.receivedAt)} ago (${lastResponse.method}).`; +} + +function describeStream( + stream: NotificationStreamState | undefined, + now: number, +): string | undefined { + if (!stream) return undefined; + const endpoint = describeStreamEndpoint(stream.url); + const events = plural(stream.eventCount, "event"); + if (stream.closedAt !== undefined) { + return `Notification stream (${endpoint}) closed ${formatDuration(now - stream.closedAt)} ago after ${formatDuration(stream.closedAt - stream.openedAt)}, ${events} delivered.`; + } + return `Notification stream (${endpoint}) open for ${formatDuration(now - stream.openedAt)}, ${events} delivered.`; +} + +/** + * One paragraph describing the connection's state as of the snapshot's own + * clock: outstanding requests, the last response, and the notification + * stream when there is one. Each clause is a sentence, so a consumer that + * wants only one of them can split on the sentence boundary rather than + * re-deriving it. + */ +export function describeConnectionDiagnostics( + diagnostics: ConnectionDiagnostics, +): string { + const now = diagnostics.capturedAt; + const parts = [ + describeOutstanding(diagnostics.outstandingRequests, now), + describeLastResponse(diagnostics.lastResponse, now), + describeStream(diagnostics.notificationStream, now), + ]; + return parts.filter((p): p is string => p !== undefined).join(" "); +} + +/** + * The data carried by an annotated timeout: the SDK's own `{ timeout }`, plus + * the method that timed out and the snapshot the message was written from. + * Exposed as a type so a consumer that wants the structured form (a details + * modal, a CLI's JSON output) can read it off `error.data` instead of parsing + * the message. + */ +export interface AnnotatedRequestTimeoutData extends RequestTimeoutData { + method: string; + diagnostics: ConnectionDiagnostics; +} + +/** + * Rebuild the SDK's per-request timeout with a message that says which + * request timed out and what the connection looked like at that moment. + * + * Anything that is not that exact error is returned untouched — the SDK's + * other `RequestTimeout` rejections (see {@link isRequestTimeoutError}), and + * every other failure — so this is safe to apply blindly on the rejection + * path of every request. + * + * The result is a fresh `SdkError` with the same code, so an `isInstance` or + * code check downstream sees exactly what it saw before; only the message and + * the data change. The original is kept as `cause`. + */ +export function annotateRequestTimeout( + err: unknown, + method: string, + diagnostics: ConnectionDiagnostics, +): unknown { + if (!isRequestTimeoutError(err)) return err; + const data: AnnotatedRequestTimeoutData = { + ...err.data, + method, + diagnostics, + }; + const message = `Request timed out after ${formatDuration(err.data.timeout)} (${method}). ${describeConnectionDiagnostics(diagnostics)}`; + const annotated = new SdkError(SdkErrorCode.RequestTimeout, message, data); + annotated.cause = err; + return annotated; +} diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index e3c32f08..eab7ae53 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -1,5 +1,7 @@ import type { ClientCapabilities } from "@modelcontextprotocol/client"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js"; +import { SKILLS_EXTENSION_KEY } from "./skillsSchemas.js"; /** * Extension identifier for SEP-2350 enterprise-managed authorization. Advertised @@ -13,21 +15,24 @@ export const EMA_EXTENSION_KEY = /** * Extension identifier for the MCP Apps UI extension (SEP-ext-apps). Mirrors * `EXTENSION_ID` from `@modelcontextprotocol/ext-apps`. Hardcoded rather than - * imported: that constant lives on the package's `/server` subpath, which would - * pull server-only code into the browser bundle. The Inspector always renders - * MCP Apps, so this is advertised by default (#1740). + * imported: as of ext-apps 2.0.0 that constant is still exported only from the + * package's `/server` subpath, which would pull server-only code (and the + * optional `@modelcontextprotocol/server` peer) into the browser bundle. The + * node integration test `extensions-mimetype.test.ts` pins the two together. + * The Inspector always renders MCP Apps, so this is advertised by default + * (#1740). */ export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui"; /** - * The MCP Apps UI resource MIME type the Inspector renders. Mirrors - * `RESOURCE_MIME_TYPE` from `@modelcontextprotocol/ext-apps`; a server checks - * for it in the client's advertised `io.modelcontextprotocol/ui` `mimeTypes` to - * decide whether to serve an App. Hardcoded (stable spec string) because - * ext-apps re-exports it through an extensionless path that doesn't resolve - * cleanly under NodeNext — see the same note in `core/mcp/apps.ts`. + * The MCP Apps UI resource MIME type the Inspector renders. A server checks for + * it in the client's advertised `io.modelcontextprotocol/ui` `mimeTypes` to + * decide whether to serve an App. Re-exported from ext-apps' `/app-bridge` + * subpath — the one the Inspector already imports everywhere — rather than + * restated: the extensionless re-export that once kept this a hardcoded copy + * was fixed upstream (ext-apps#705) and shipped in 2.0.0 (#1745). */ -export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; +export const MCP_APP_MIME_TYPE = RESOURCE_MIME_TYPE; /** * The value the client stamps for each advertised extension. The wire shape is @@ -87,6 +92,18 @@ export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [ defaultAdvertised: true, advertisement: { mimeTypes: [MCP_APP_MIME_TYPE] }, }, + { + key: SKILLS_EXTENSION_KEY, + label: "Skills (io.modelcontextprotocol/skills)", + // The Skills extension (SEP-2640). SEP-2133 negotiates an extension from + // both sides, so a server may refuse `skills/list`, `skills/get` and + // `resources/directory/read` to a client that did not declare it — and the + // Inspector calls all three once the server declares its half. Advertised + // by default for that reason; turning it off is how to check a server's + // refusal path. Declared with no settings: SEP-2640 defines none for the + // client side. (#2373) + defaultAdvertised: true, + }, ]; export interface BuildClientExtensionsInput { diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 7e1e5864..d4cce790 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -1,4 +1,4 @@ -import type { FetchRequestEntryBase } from "./types.js"; +import type { FetchRequestEntryBase, FetchStreamState } from "./types.js"; /** * Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a @@ -150,6 +150,14 @@ function redactJsonValue(value: unknown): unknown { * (and other binary/opaque bodies) are passed through verbatim — OAuth never uses * multipart, so the risk is low; revisit if a multipart secret path appears. */ +export function redactBody( + body: string, + contentType: string | null | undefined, +): string; +export function redactBody( + body: string | undefined, + contentType: string | null | undefined, +): string | undefined; export function redactBody( body: string | undefined, contentType: string | null | undefined, @@ -190,11 +198,18 @@ export function redactBody( return body; } -/** Case-insensitive lookup of a header value from a plain header record. */ -function findHeader( - headers: Record, +/** + * Case-insensitive lookup of a header value from a plain header record. + * Exported because a recorded entry's header casing is host-dependent (Node + * lowercases, happy-dom preserves the wire casing), so any consumer reading + * one back — the Network UI, the connection diagnostics — needs the same + * tolerance the tracker itself has. + */ +export function findHeader( + headers: Record | undefined, name: string, ): string | undefined { + if (!headers) return undefined; const target = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === target) return value; @@ -215,14 +230,108 @@ export function isLongLivedStreamResponse( method: string, contentType: string | null | undefined, ): boolean { - if (method !== "GET") return false; + if (method.toUpperCase() !== "GET") return false; if (!contentType) return false; + // Media types are case-insensitive (RFC 9110 §8.3.1); a server may send + // `Text/Event-Stream` and still be sending an event stream. + const type = contentType.toLowerCase(); return ( - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") + type.includes("text/event-stream") || type.includes("application/x-ndjson") ); } +/** Most of a 401/403 body the tracker will buffer before it stops reading. */ +export const CHALLENGE_BODY_MAX_BYTES = 64 * 1024; +/** How long the tracker waits for a 401/403 body before giving up on the rest. */ +export const CHALLENGE_BODY_READ_MS = 2_000; +/** Appended to a 401/403 body the tracker stopped reading early. */ +export const TRUNCATED_BODY_SUFFIX = "\n[truncated]"; +/** + * Recorded in place of a 401/403 body that was cut off and looks like JSON. + * {@link redactBody} can only mask fields in JSON that parses, so a cut-off + * prefix such as `{"access_token":"…",` would otherwise reach the log verbatim. + */ +export const WITHHELD_TRUNCATED_JSON_BODY = + "[truncated JSON body withheld: an incomplete body cannot be redacted]"; + +interface ChallengeBodyRead { + text: string; + truncated: boolean; +} + +/** + * The recorded form of a 401/403 body: redacted, marked when cut off, and never + * a cut-off JSON prefix. Form-encoded bodies still redact when cut, since each + * surviving `key=value` pair is parsed on its own; plain text has nothing to + * redact either way. + */ +function recordableChallengeBody( + read: ChallengeBodyRead, + contentType: string | null, +): string { + if (!read.truncated) return redactBody(read.text, contentType); + const looksLikeJson = + (contentType ?? "").toLowerCase().includes("json") || + /^\s*[{[]/.test(read.text); + if (looksLikeJson) return WITHHELD_TRUNCATED_JSON_BODY; + return redactBody(read.text, contentType) + TRUNCATED_BODY_SUFFIX; +} + +/** + * Read a 401/403 response body from a clone, bounded by + * {@link CHALLENGE_BODY_MAX_BYTES} and {@link CHALLENGE_BODY_READ_MS}. + * + * Unlike every other status, a challenge body is read *before* the entry is + * recorded (#2297). An auth-challenge interceptor above the tracker cancels the + * response and throws as soon as the tracker returns, and on the web backend + * that ends the connect request — so a body delivered later, as a separate + * update, arrives after anything could still carry it to the log. The bound is + * what makes waiting safe: a body that never ends is cut off, and cancelling the + * clone's reader lets the interceptor's cancel of the original release the + * connection instead of leaving the clone draining it. + */ +async function readChallengeBody( + response: Response, +): Promise { + let reader: ReadableStreamDefaultReader; + try { + const body = response.clone().body; + /* v8 ignore next -- the caller checked response.body, and a clone of a response with a body has one */ + if (!body) return undefined; + reader = body.getReader(); + } catch { + return undefined; + } + + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let timer: ReturnType | undefined; + const deadline = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), CHALLENGE_BODY_READ_MS); + }); + try { + while (bytes < CHALLENGE_BODY_MAX_BYTES) { + const next = await Promise.race([reader.read(), deadline]); + if (next === "timeout") break; + if (next.done) return { text: text + decoder.decode(), truncated: false }; + // A stream may hand back one chunk far larger than the budget, so the cap + // is applied to the bytes, not to the chunk count. + const chunk = next.value.subarray(0, CHALLENGE_BODY_MAX_BYTES - bytes); + bytes += chunk.byteLength; + text += decoder.decode(chunk, { stream: true }); + } + // Stopped early. Not awaited: cancel() adopts the source's promise, which a + // stalled body may never settle. + reader.cancel().catch(() => {}); + return { text: text + decoder.decode(), truncated: true }; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + export interface FetchTrackingCallbacks { trackRequest?: (entry: FetchRequestEntryBase) => void; /** @@ -233,6 +342,194 @@ export interface FetchTrackingCallbacks { * never invoked and the entry's responseBody stays undefined. */ updateResponseBody?: (id: string, responseBody: string) => void; + /** + * Called for a long-lived stream response — the one case `updateResponseBody` + * never covers — each time an SSE event is delivered on it, and once more + * when it ends (with `closedAt` set). Lets the consumer show that the stream + * is open and what it has carried, which is the whole diagnostic when a + * server serializes requests behind it (#2318). + */ + updateStream?: (id: string, stream: FetchStreamState) => void; + /** + * How often, at most, `updateStream` reports a moving event count, in ms. + * The first report (open, zero events) and the close are immediate; counts + * in between are coalesced so a server pushing hundreds of notifications a + * second costs one update per window rather than one per event — each + * update re-publishes the whole fetch log and, on the web client, crosses + * the backend-to-browser event stream. Defaults to + * `DEFAULT_STREAM_UPDATE_INTERVAL_MS`; `0` reports every event, for tests. + */ + streamUpdateIntervalMs?: number; +} + +/** See `FetchTrackingCallbacks.streamUpdateIntervalMs`. */ +export const DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250; + +/** + * The framing a long-lived stream's events arrive in — the two content types + * `isLongLivedStreamResponse` accepts. SSE dispatches on a blank line; + * NDJSON dispatches on every line. + */ +export type LongLivedStreamFraming = "sse" | "ndjson"; + +/** The framing for a long-lived stream's content type. */ +export function longLivedStreamFraming( + contentType: string | null | undefined, +): LongLivedStreamFraming { + return contentType?.toLowerCase().includes("application/x-ndjson") + ? "ndjson" + : "sse"; +} + +/** + * Count the events on a long-lived stream without buffering it, and report + * when it ends. + * + * Reads a `clone()` of the response through a reader and discards the bytes, + * so the transport keeps consuming the original at its own pace (the same + * tee the bounded-body path relies on). Counting follows the framing the + * transport's own parser applies. For SSE a blank line dispatches the block + * before it, and only a block carrying a `data` field is an event — + * keepalive comments (`: ping`) and bare `event:`/`id:` lines are not; a + * partial block at end-of-stream is dropped, exactly as the SSE spec and the + * SDK's `EventSourceParserStream` (which has no flush) drop it. For NDJSON + * every non-blank line is one event. + * + * Reports once as soon as watching starts (zero events, open), once per event, + * and once more on every exit from the read loop — a clean end-of-stream, a + * network error, or the transport aborting the fetch on disconnect (which + * errors both tee branches) — with `closedAt` set. The reader is never cancelled from here: + * cancelling one branch of a tee does not release the source, and + * `ReadableStream.cancel()` adopts the source's promise, which on a teardown + * path may never settle (the same hazard `core/mcp/remote/node/server.ts` + * documents at its own `cancel()` call). + */ +function watchLongLivedStream( + response: Response, + id: string, + framing: LongLivedStreamFraming, + callbacks: FetchTrackingCallbacks, +): void { + const listener = callbacks.updateStream; + if (!listener) return; + // Every report goes through here: the listener is consumer code (a relay + // sink that may already be closed, a state store), and it runs from three + // places none of which may throw outward — synchronously inside the fetch + // wrapper (the open), from a timer (a coalesced count), and off the + // discarded watcher promise (the close). A throw is the listener's + // problem; the stream's bookkeeping carries on. + const report = (state: FetchStreamState): void => { + try { + listener(id, state); + } catch { + // See above. + } + }; + const intervalMs = + callbacks.streamUpdateIntervalMs ?? DEFAULT_STREAM_UPDATE_INTERVAL_MS; + let body: ReadableStream | null; + try { + body = response.clone().body; + } catch { + // Clone failed (consumed body, transport quirks) — there is nothing to + // watch; the entry stays as tracked with no stream state. + return; + } + if (!body) return; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let eventCount = 0; + let pending = ""; + let blockHasData = false; + // A moving count is reported at most once per interval (see + // `streamUpdateIntervalMs`); the open and the close go out at once. + let reportTimer: ReturnType | null = null; + const reportCount = (): void => { + if (intervalMs <= 0) { + report({ eventCount }); + return; + } + if (reportTimer !== null) return; + reportTimer = setTimeout(() => { + reportTimer = null; + report({ eventCount }); + }, intervalMs); + }; + // Announce the stream as open before a byte arrives. A stream that never + // delivers anything is the #2187 tell, and it would otherwise be the one + // stream with no state to show — the count only moves on an event. + report({ eventCount }); + + const consumeLine = (line: string): void => { + if (framing === "ndjson") { + if (line.trim() === "") return; + eventCount += 1; + reportCount(); + return; + } + if (line === "") { + if (blockHasData) { + eventCount += 1; + blockHasData = false; + reportCount(); + } + return; + } + // A field line is `name[:value]`; `data` with no colon is still a data + // field per the spec, so match the name rather than the `data:` prefix. + if (line === "data" || line.startsWith("data:")) blockHasData = true; + }; + + // Split off every complete line in `pending`. The SSE grammar (and the + // SDK's parser) accept LF, CRLF and a bare CR as a line ending, so all three + // end a line here; a CR that is the last byte so far is held back, since + // the LF of a CRLF pair may still be in flight in the next chunk. + const consumeCompleteLines = (): void => { + for (;;) { + const cr = pending.indexOf("\r"); + const lf = pending.indexOf("\n"); + const end = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf); + if (end === -1) return; + if (pending[end] === "\r") { + if (end === pending.length - 1) return; + const width = pending[end + 1] === "\n" ? 2 : 1; + consumeLine(pending.slice(0, end)); + pending = pending.slice(end + width); + } else { + consumeLine(pending.slice(0, end)); + pending = pending.slice(end + 1); + } + } + }; + + const pump = async (): Promise => { + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + pending += decoder.decode(value, { stream: true }); + consumeCompleteLines(); + } + // A CR held back as a possible half of a CRLF is, at end-of-stream, a + // whole line ending — `data: x\r\r` is a complete event. Anything after + // it is a partial block and is dropped, as above. + if (pending.endsWith("\r")) consumeLine(pending.slice(0, -1)); + } catch { + // An errored or aborted stream has still ended — fall through to report + // the close; the entry's `error` field is for a fetch that never got a + // response, which this one did. + } + if (reportTimer !== null) { + clearTimeout(reportTimer); + reportTimer = null; + } + report({ eventCount, closedAt: new Date() }); + }; + // Deliberately not awaited: the fetch wrapper has to return the response + // now, and this watcher lives as long as the stream does. It owns its own + // failures — the read loop is wrapped in a `try`, and every report goes + // through the non-throwing `report` above — so the promise cannot reject. + void pump(); } /** @@ -257,7 +554,12 @@ export function createFetchTracker( : input instanceof URL ? input.toString() : input.url; - const method = init?.method || "GET"; + // The method decides the long-lived-stream classification below, so it + // has to be the one the request actually carries: a `Request` input + // carries its own, and HTTP methods compare case-insensitively. + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); // Extract headers, redacting sensitive values BEFORE they reach any // downstream sink (logger, in-memory list, persisted session storage). @@ -376,8 +678,36 @@ export function createFetchTracker( duration, }; + // Not gated on isLongLivedStream: that exclusion guards an unbounded read, + // and this one is bounded — so a challenge to an SSE GET is recorded too. + if ( + (responseStatus === 401 || responseStatus === 403) && + response.body && + !response.bodyUsed + ) { + const read = await readChallengeBody(response); + entry.responseBody = + read === undefined + ? undefined + : recordableChallengeBody(read, response.headers.get("content-type")); + callbacks.trackRequest?.(entry); + return response; + } + callbacks.trackRequest?.(entry); + // A long-lived stream is watched instead of read: its body never ends, + // but its event count and its eventual close are exactly the state a + // stalled connection is diagnosed from (#2318). + if (isLongLivedStream && response.body && !response.bodyUsed) { + watchLongLivedStream( + response, + id, + longLivedStreamFraming(response.headers.get("content-type")), + callbacks, + ); + } + // Kick off a fire-and-forget read of the cloned body. The clone is an // independent tee'd stream so the transport keeps consuming the // original at its own pace. When the read resolves we patch the entry diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cec72b49..04293238 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -13,6 +13,7 @@ import type { MessageOrigin, FetchRequestEntry, FetchRequestEntryBase, + FetchStreamState, InspectorServerSettings, ResourceReadInvocation, ResourceTemplateReadInvocation, @@ -67,6 +68,7 @@ import type { CreateTransportOptions, ServerType, } from "./types.js"; +import { DEFAULT_CONNECTION_TIMEOUT_MS } from "./types.js"; import { MessageTrackingTransport, type MessageTrackingCallbacks, @@ -148,8 +150,6 @@ import { DirectoryReadResultSchema, GetSkillEnvelopeSchema, ListSkillsResultSchema, - ModernGetSkillEnvelopeSchema, - ModernDirectoryReadResultSchema, ModernListSkillsResultSchema, RESOURCES_DIRECTORY_READ_METHOD, SKILLS_EXTENSION_KEY, @@ -256,13 +256,39 @@ import { } from "../auth/challenge.js"; import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js"; +import { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + exemptMcpEndpoint, + withOAuthRequestTimeout, +} from "../auth/requestTimeout.js"; import type { TokenRevocationOutcome } from "../auth/revocation.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; -import { createFetchTracker } from "./fetchTracking.js"; +import { + createFetchTracker, + findHeader, + isLongLivedStreamResponse, +} from "./fetchTracking.js"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { + annotateRequestTimeout, + isRequestTimeoutError, + type ConnectionDiagnostics, + type LastResponse, + type NotificationStreamState, + type OutstandingRequest, +} from "./connectionDiagnostics.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; import { RemoteClientTransport } from "./remote/remoteClientTransport.js"; +/** + * The notification stream the diagnostics are following, plus the fetch entry + * id that ties later `fetchRequestStreamUpdate`s back to it (#2318). + */ +interface TrackedNotificationStream extends NotificationStreamState { + id: string; +} + /** Internal record for a receiver task (server polls us for status/result). */ interface ReceiverTaskRecord { task: Task; @@ -288,6 +314,20 @@ interface ReceiverTaskRecord { */ const MAX_URL_ELICITATION_RETRIES = 5; +/** + * 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. + */ +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.` + ); +} + /** * Error used to reject a pending sampling/elicitation request when the tool * call driving its MRTR round is aborted (e.g. the user hits Cancel). Its @@ -361,24 +401,6 @@ async function closeSubscriptionBestEffort( } } -/** - * Extract the method literal from an MCP notification Zod schema (e.g. - * `ToolListChangedNotificationSchema`), or `undefined` if the shape isn't - * recognized. Used by the App-renderer client proxy to translate the SDK-v1 - * schema-first `setNotificationHandler` API — which `@modelcontextprotocol/ext-apps` - * still uses — into SDK v2's method-string form. Reads the `method` literal off - * the notification schema's `shape` (the shape both the v1 SDK and v2 core - * schemas expose). - */ -function notificationMethodFromSchema(schema: unknown): string | undefined { - if (schema !== null && typeof schema === "object") { - const literal = (schema as { shape?: { method?: { value?: unknown } } }) - .shape?.method?.value; - if (typeof literal === "string") return literal; - } - return undefined; -} - /** * The descriptor for a single tools/call, threaded through the retry loop and * each attempt. Bundled into one object so `callToolWithRetries`/`attemptToolCall` @@ -483,19 +505,28 @@ export class InspectorClient extends InspectorClientEventTarget { */ private static readonly MRTR_MAX_ROUNDS = 10; private client: Client | null = null; - private appRendererClientProxy: AppRendererClient | null = null; // Lazily-built validator used only on the skipOutputValidation path to detect // (non-fatally) when a delivered result violates the tool's outputSchema. private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; - // Correlation for `markResponseRejected` (#1953): the method of each - // outbound request still awaiting a response, and — once one is answered — - // the id of the most recently answered request per method. Entries are - // dropped as responses arrive, so this holds at most one id per method - // rather than growing with the session. - private outboundRequestMethods = new Map(); + // Every outbound request still awaiting a response, keyed by JSON-RPC id. + // Serves two readers: the `markResponseRejected` correlation (#1953), which + // needs the method, and the connection diagnostics (#2318), which need the + // method and when it went out. Entries are dropped as responses arrive; a + // request the SDK gave up on (a timeout, a cancel) stays until the session + // resets, because it is still unanswered and that is what the diagnostics + // report. + private outstandingRequests = new Map(); + // The id of the most recently answered request per method, for + // `markResponseRejected` (#1953). At most one id per method, so it does not + // grow with the session. private lastAnsweredRequestByMethod = new Map(); + // Connection diagnostics (#2318): the most recent response to one of our + // requests, and the most recent long-lived transport stream (the standalone + // `GET` on Streamable HTTP). Both per-session, reset with the maps above. + private lastResponse: LastResponse | undefined; + private notificationStream: TrackedNotificationStream | undefined; /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; /** Dedupes concurrent ambient auth challenges (reason + scopes). */ @@ -810,8 +841,9 @@ export class InspectorClient extends InspectorClientEventTarget { // `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is // created a few lines below, and `setOAuthConfig` can change them later — so // the wrapper is inert until a server actually configures one. - this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () => - this.oauthManager?.getEndpointOverrides(), + const withOverrides = withOAuthEndpointOverrides( + this.fetchFn ?? fetch, + () => this.oauthManager?.getEndpointOverrides(), ); // #2172: recover discovery when a plain OAuth 2.0 authorization server // publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which @@ -822,8 +854,40 @@ export class InspectorClient extends InspectorClientEventTarget { // still hit the upstream failure. The substituted response is stamped with // `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came // from rather than appearing to be a 200 from the RFC 8414 path. - this.fetchFn = withRfc8414OidcCompat(this.fetchFn); - this.effectiveAuthFetch = this.buildEffectiveAuthFetch(); + // The transport chain is bounded too, but only for the requests on it that + // are *not* MCP traffic — the OAuth work the SDK runs from inside the + // transport on the 401/refresh path, which otherwise waits out the SDK's + // incidental per-request timeout and reports a bare `Request timed out` + // naming no endpoint (Copilot). The MCP endpoint itself is exempt, and the + // predicate fails open: a long-running tool call withholding its response + // headers for minutes, or an SSE body that stays open, must never be timed + // here. Deadline innermost, for the same reason as the auth chain below. + this.fetchFn = withRfc8414OidcCompat( + withOAuthRequestTimeout( + withOverrides, + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + exemptMcpEndpoint( + () => this.getServerUrl(), + // Legacy SSE negotiates its message endpoint inside the stream, so + // its path is unknowable here and the exemption has to widen to the + // origin. Streamable HTTP sends everything to the configured URL, so + // the path rule is exact there and same-origin OAuth stays bounded. + () => this.transportConfig?.type === "sse", + ), + ), + ); + // #2319: the auth chain is composed separately so the deadline sits + // *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put + // it outside, and then a stalled OIDC probe would be reported under the + // URL of the RFC 8414 request that preceded it — defeating the whole point + // of naming the endpoint — while every probe in the loop shared that one + // request's budget (Copilot). Innermost here matches the CLI's + // `storedAuthFetch`. It also passes no `isExempt`, unlike the transport + // chain above: every request on this chain is OAuth work, so there is + // nothing here to exempt. + this.effectiveAuthFetch = this.buildEffectiveAuthFetch( + withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)), + ); this.sessionId = options.sessionId; @@ -979,7 +1043,6 @@ export class InspectorClient extends InspectorClientEventTarget { this.rootsListChangedCapabilityAdvertised = capabilities.roots?.listChanged === true; - this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, version: corePackageJson.version, @@ -988,10 +1051,65 @@ export class InspectorClient extends InspectorClientEventTarget { this.clientInfo, Object.keys(clientOptions).length > 0 ? clientOptions : undefined, ); + this.annotateSdkRequestTimeouts(this.client); } - private buildEffectiveAuthFetch(): typeof fetch { - const base = this.fetchFn ?? fetch; + /** + * Decorate the SDK Client's `request` so a per-request timeout rejects with + * the connection's state at that moment instead of a bare `Request timed + * out` (#2318). + * + * `Protocol.request` is the one funnel every SDK verb goes through — + * `listTools`, `callTool`, `readResource`, the handshake's `initialize`, + * and this class's own raw `client.request` calls all reach the wire + * through it — so decorating it here covers the whole surface without a + * `catch` at each of the several dozen call sites, and covers the next + * verb added without anyone remembering to. The SDK offers no hook on the + * rejection path: the timeout error is constructed and thrown inside + * `request()` itself. + * + * Replaced on the instance rather than through a subclass because + * `request` is declared with two overloads (spec method with a keyed + * result, custom method with an explicit schema), and an `override` would + * have to restate both against types that move with every SDK release. One + * implementation signature serves both at runtime; the single cast on the + * way back is what the overloads cost. + */ + private annotateSdkRequestTimeouts(client: Client): void { + type SdkRequest = Client["request"]; + const sdkRequest: SdkRequest = client.request.bind(client); + const decorated = async ( + ...args: Parameters + ): Promise => { + try { + return await sdkRequest(...args); + } catch (err) { + throw annotateRequestTimeout( + err, + args[0].method, + this.getConnectionDiagnostics(), + ); + } + }; + client.request = decorated as SdkRequest; + } + + /** + * @param base - the composed OAuth-path fetch, deadline innermost (#2319). + * + * Passed in rather than read off `this.fetchFn` because the two chains are + * bounded on different terms, not because one of them is unbounded. Both + * carry a deadline; the transport chain additionally exempts the MCP endpoint + * (`exemptMcpEndpoint`), since its bodies are streams meant to stay open and + * a long-running tool call may withhold its headers for minutes. Reusing it + * here would extend that exemption to the OAuth manager's own requests — and + * an authorization server published at the MCP endpoint's path would then go + * unbounded on the one chain that exists to bound it. + * + * The SDK's transport-internal discovery (the 401/refresh path) is covered by + * the transport chain's own deadline, not by this one. + */ + private buildEffectiveAuthFetch(base: typeof fetch): typeof fetch { // Capture auth response bodies (OAuth discovery, DCR, token exchange) so // they're inspectable in the Network tab. Token-exchange responses carry // `access_token` / `refresh_token`; the Network UI masks those (and other @@ -1011,7 +1129,12 @@ export class InspectorClient extends InspectorClientEventTarget { return { trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => { if (origin === "client") { - this.outboundRequestMethods.set(message.id, message.method); + this.outstandingRequests.set(message.id, { + id: message.id, + method: message.method, + sentAt: Date.now(), + }); + this.dispatchConnectionDiagnosticsChange(); } const entry: MessageEntry = { id: crypto.randomUUID(), @@ -1022,6 +1145,20 @@ export class InspectorClient extends InspectorClientEventTarget { }; this.dispatchTypedEvent("message", entry); }, + trackSendFailure: (message: JSONRPCRequest, error: unknown) => { + // A send that failed because the request timed out is not a send + // that failed: the browser's remote transport awaits the response + // inside `send`, so its relay timeout surfaces here — the request + // reached the server and went unanswered, which is the state to + // keep reporting. Every other failure means the frame never reached + // the wire, and leaving it would report a request the server never + // saw as unanswered, in every timeout message and Connection Info + // row, until the session resets (#2318). + if (isRequestTimeoutError(error)) return; + if (this.outstandingRequests.delete(message.id)) { + this.dispatchConnectionDiagnosticsChange(); + } + }, trackResponse: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, @@ -1033,11 +1170,13 @@ export class InspectorClient extends InspectorClientEventTarget { // frame answers no specific request, so it is skipped. const responseId = message.id; if (origin === "server" && responseId !== undefined) { - const method = this.outboundRequestMethods.get(responseId); - this.outboundRequestMethods.delete(responseId); + const method = this.outstandingRequests.get(responseId)?.method; + this.outstandingRequests.delete(responseId); if (method !== undefined) { this.lastAnsweredRequestByMethod.set(method, responseId); + this.lastResponse = { method, receivedAt: Date.now() }; } + this.dispatchConnectionDiagnosticsChange(); } const entry: MessageEntry = { id: crypto.randomUUID(), @@ -1896,8 +2035,11 @@ export class InspectorClient extends InspectorClientEventTarget { // start-clean path rather than in `disconnect()` for the reason documented // above: one route out (`onerror` with no `onclose`) tears down nothing // (#1953). - this.outboundRequestMethods.clear(); + this.outstandingRequests.clear(); this.lastAnsweredRequestByMethod.clear(); + this.lastResponse = undefined; + this.notificationStream = undefined; + this.dispatchConnectionDiagnosticsChange(); // Per-session for the same reason: both name entries of the PREVIOUS // server's list. Cleared here as well as in `disconnect()` because the // route out that tears down nothing (`onerror` with no `onclose`) would @@ -2066,10 +2208,14 @@ export class InspectorClient extends InspectorClientEventTarget { }, onFetchRequest: (entry: FetchRequestEntryBase) => { this.dispatchFetchRequest({ ...entry, category: "transport" }); + this.noteTransportStream(entry); }, onFetchResponseBody: (id: string, body: string) => { this.dispatchFetchRequestBodyUpdate(id, body); }, + onFetchStreamUpdate: (id: string, stream: FetchStreamState) => { + this.dispatchFetchRequestStreamUpdate(id, stream); + }, ...(this.serverSettings && { settings: this.serverSettings }), }; if (this.isHttpOAuthConfig() && oauthManager) { @@ -2188,11 +2334,28 @@ export class InspectorClient extends InspectorClientEventTarget { this.registerPeerRequestHandlers(); this.registerPeerNotificationHandlers(); - // Optional connect-time timeout from per-server settings. The MCP SDK - // has no connect-time timeout option, so we wrap the handshake in a + // Connect-time timeout from per-server settings, defaulting to + // `DEFAULT_CONNECTION_TIMEOUT_MS` when the settings carry none. The MCP + // SDK has no connect-time timeout option, so we wrap the handshake in a // Promise.race. On timeout, tear the transport down so the next // connect() starts clean and the upstream socket isn't left hanging. - const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0; + // + // The default is ours on purpose (#2320): without it the only bound on + // a connect attempt was the SDK's per-request timeout on `initialize`, + // which covers nothing outside that request (`transport.start()` for + // one), fires after 60 s, and reports `Request timed out` — a sentence + // about a JSON-RPC request, not a connection. An explicit `0` is still + // "no timeout": that is what the CLI's `--connect-timeout 0` documents + // and what a user who cleared the field asked for. + const connectTimeoutMs = + this.serverSettings?.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS; + // Set only by the timer below, so the teardown in the catch runs for a + // timeout and nothing else. Gating it on `connectTimeoutMs > 0` instead + // would tear down *every* failed connect once the default is non-zero — + // including a recoverable 401, whose transport the auth recovery needs + // held open and whose status must stay at "connecting" (see the outer + // catch and `transportHasAuthProvider`). + let connectTimedOut = false; // Unwrap here — the earliest point — so an auth error the SDK's // era-negotiation probe buried in its cause chain is surfaced before // anything downstream inspects it: `withDirectAuthRecovery` (whose @@ -2236,15 +2399,10 @@ export class InspectorClient extends InspectorClientEventTarget { connectPromise.catch(() => {}); let timer: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `Connection timed out after ${connectTimeoutMs} ms`, - ), - ), - connectTimeoutMs, - ); + timer = setTimeout(() => { + connectTimedOut = true; + reject(new Error(connectionTimeoutMessage(connectTimeoutMs))); + }, connectTimeoutMs); }); try { await Promise.race([connectPromise, timeoutPromise]); @@ -2259,7 +2417,7 @@ export class InspectorClient extends InspectorClientEventTarget { try { await this.invokeMcpClient(runConnect); } catch (err) { - if (connectTimeoutMs > 0) { + if (connectTimedOut) { await this.disconnect().catch(() => {}); } throw err; @@ -2560,7 +2718,6 @@ export class InspectorClient extends InspectorClientEventTarget { this.activeToolCallAbortController?.abort("Disconnected"); this.activeToolCallAbortController = undefined; this.clearReceiverTasks(); - this.appRendererClientProxy = null; this.capabilities = undefined; this.serverInfo = undefined; this.instructions = undefined; @@ -2592,44 +2749,19 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Returns a client proxy for use by AppRenderer / @mcp-ui. Delegates to the - * internal MCP Client. Returns null when not connected. Use this instead of - * accessing the raw client so behavior can be adapted here later if needed. + * The SDK client for the MCP Apps host bridge, or null when not connected. + * + * ext-apps' `AppBridge` (2.0.0+, an SDK v2 peer) takes the v2 `Client` + * directly and registers its list-changed forwarding on it in the + * method-string form, so this hands out the real client. The Proxy that + * translated the 1.x peer's schema-first `setNotificationHandler(Schema, + * handler)` into that form is gone with it (#1745). Kept as the seam rather + * than exposing the field so the connected-status gate lives in one place + * and hooks, fakes and tests depend on the protocol, not the class. */ getAppRendererClient(): AppRendererClient | null { if (!this.client || this.status !== "connected") return null; - if (this.appRendererClientProxy !== null) - return this.appRendererClientProxy; - const target = this.client; - this.appRendererClientProxy = new Proxy(this.client, { - get(proxyTarget, prop, receiver) { - const value = Reflect.get(proxyTarget, prop, receiver); - if (prop === "setNotificationHandler" && typeof value === "function") { - return (schemaOrMethod: unknown, ...rest: unknown[]) => { - // `@modelcontextprotocol/ext-apps` still peers on SDK v1 and - // subscribes to list-changed notifications with the v1 schema-first - // API `setNotificationHandler(NotificationSchema, handler)`. SDK v2 - // requires a method STRING as the first argument and throws - // "'[object Object]' is not a spec notification method" on a schema — - // which broke App rendering during the initial connect handshake. - // Translate a schema-first call to the method-string form; native - // string-first calls (ours) pass through untouched. Remove when - // ext-apps#702 ships a v2 peer. - const method = - typeof schemaOrMethod === "string" - ? schemaOrMethod - : (notificationMethodFromSchema(schemaOrMethod) ?? - schemaOrMethod); - return (value as (...a: unknown[]) => unknown).apply(target, [ - method, - ...rest, - ]); - }; - } - return value; - }, - }) as AppRendererClient; - return this.appRendererClientProxy; + return this.client; } /** @@ -2824,8 +2956,19 @@ export class InspectorClient extends InspectorClientEventTarget { const raw = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRawWireRequests.delete(id); + // The same error, with the same annotation, as an SDK request that + // times out: this path bypasses `Protocol.request`, so it builds the + // SDK's own timeout shape and runs it through the decorator's + // annotation by hand — a raw-wire caller sees one kind of timeout, + // not two (#2318). reject( - new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`), + annotateRequestTimeout( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: timeoutMs, + }), + method, + this.getConnectionDiagnostics(), + ), ); }, timeoutMs); this.pendingRawWireRequests.set(id, { resolve, reject, timer }); @@ -2835,7 +2978,17 @@ export class InspectorClient extends InspectorClientEventTarget { clearTimeout(pending.timer); this.pendingRawWireRequests.delete(id); } - reject(err instanceof Error ? err : new Error(String(err))); + // The browser's remote transport awaits the response inside `send`, + // so its relay wait can expire here first, as the SDK's timeout + // shape; annotate it exactly as the local timer above does (a + // non-timeout error passes through untouched). + reject( + annotateRequestTimeout( + err instanceof Error ? err : new Error(String(err)), + method, + this.getConnectionDiagnostics(), + ), + ); }); }); return resultSchema.parse(raw); @@ -5586,11 +5739,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...(cursor !== undefined ? { cursor } : {}), }; // Era-aware: a modern (2026-07-28+) `skills/list` result also carries the - // base list envelope (`resultType` / `ttlMs` / `cacheScope`). `skills/*` is - // consumer-owned, so the SDK codec validates none of it — without picking - // the schema here a modern server could answer `{ skills: [] }` and the - // conformance UI would show a clean list. Legacy stays permissive: those - // are 2026-era attributes. + // base list envelope (`resultType` / `ttlMs` / `cacheScope`). The two halves + // are checked in different places: the SDK codec checks `resultType` on + // every modern result and removes it before this schema runs (#2373), but + // `skills/*` is consumer-owned, so it checks neither caching attribute — + // without picking the schema here a modern server could answer + // `{ skills: [] }` and the conformance UI would show a clean list. Legacy + // stays permissive: those are 2026-era attributes. const resultSchema = this.isModernEra() ? ModernListSkillsResultSchema : ListSkillsResultSchema; @@ -5620,8 +5775,10 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * `skills/get` as the server sent it — the `{ skill }` envelope **and any - * other members it carried**. + * `skills/get` as the SDK decoded it — the `{ skill }` envelope **and any + * other members the server sent**, such as the caching attributes. On a + * modern connection `resultType` is not among them: the codec checks it and + * removes it before the result gets here (#2373). * * Separate from {@link getSkill} because the callers differ: the UIs want the * entry, while the CLI prints the result and must not reshape it. SEP-2640 @@ -5641,21 +5798,16 @@ export class InspectorClient extends InspectorClientEventTarget { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - // Era-aware for the same reason `skills/list` is: the method is - // consumer-owned, so no SDK codec stamps or checks its envelope. The modern - // variant requires `resultType` — a base-protocol member SEP-2322 puts on - // every modern result — and still not the caching attributes, which - // SEP-2640 leaves open. The envelope is returned whole; `getSkill` - // unwraps. - const resultSchema = this.isModernEra() - ? ModernGetSkillEnvelopeSchema - : GetSkillEnvelopeSchema; + // One schema for both eras (#2373): on a modern connection the SDK codec + // has already enforced `resultType` and lifted it off, and the caching + // attributes are left open by SEP-2640, so there is nothing era-specific + // left to require. The envelope is returned whole; `getSkill` unwraps. try { return await this.invokeMcpClient( () => this.client!.request( { method: SKILLS_GET_METHOD, params }, - resultSchema, + GetSkillEnvelopeSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: SKILLS_GET_METHOD }, @@ -5717,19 +5869,16 @@ export class InspectorClient extends InspectorClientEventTarget { // for page one. ...(cursor !== undefined ? { cursor } : {}), }; - // Era-aware for the same reason `skills/list` is — the method is - // consumer-owned, so no SDK codec stamps or checks its envelope. The modern - // variant requires only `resultType`; see the schema for why it stops - // short of the caching attributes that `skills/list` requires. - const resultSchema = this.isModernEra() - ? ModernDirectoryReadResultSchema - : DirectoryReadResultSchema; + // One schema for both eras (#2373): the SDK codec enforces and lifts + // `resultType` on a modern connection, and SEP-2640 requires no caching + // attributes of this method — see `skillsSchemas.ts` for why that differs + // from `skills/list`. try { return await this.invokeMcpClient( () => this.client!.request( { method: RESOURCES_DIRECTORY_READ_METHOD, params }, - resultSchema, + DirectoryReadResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: RESOURCES_DIRECTORY_READ_METHOD }, @@ -5956,6 +6105,87 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("fetchRequestBodyUpdate", { id, responseBody }); } + private dispatchFetchRequestStreamUpdate( + id: string, + stream: FetchStreamState, + ): void { + this.dispatchTypedEvent("fetchRequestStreamUpdate", { id, stream }); + // The diagnostics follow only the stream they are currently watching; + // an update for an older one (a reconnected stream's predecessor + // closing late) changes nothing they report. + if (this.notificationStream?.id !== id) return; + this.notificationStream = { + ...this.notificationStream, + eventCount: stream.eventCount, + ...(stream.closedAt && { closedAt: stream.closedAt.getTime() }), + }; + this.dispatchConnectionDiagnosticsChange(); + } + + /** + * Adopt a transport fetch as the notification stream when it is one — a + * `GET` answered with an unbounded event stream — so the diagnostics can + * say how long it has been open and what it has delivered (#2318). The + * newest such stream wins: the SDK reopens the standalone `GET` after a + * drop, and the current one is the one a stalled request is queued behind. + */ + private noteTransportStream(entry: FetchRequestEntryBase): void { + if ( + !isLongLivedStreamResponse( + entry.method, + findHeader(entry.responseHeaders, "content-type"), + ) + ) { + return; + } + this.notificationStream = { + id: entry.id, + url: entry.url, + // The entry's `timestamp` is when the request went out; the stream is + // open from when the response headers arrived, `duration` later. + openedAt: entry.timestamp.getTime() + (entry.duration ?? 0), + eventCount: 0, + }; + this.dispatchConnectionDiagnosticsChange(); + } + + /** + * What this client is still waiting on, when it last heard back, and the + * state of the notification stream (#2318). A fresh snapshot per call — + * the same one `annotateRequestTimeout` writes a timeout message from, and + * the one `connectionDiagnosticsChange` carries. + */ + getConnectionDiagnostics(): ConnectionDiagnostics { + return { + capturedAt: Date.now(), + // Copies, not the tracked records: the snapshot is handed to callers + // and carried on errors, and a mutation there must not reach the + // correlation state. + outstandingRequests: [...this.outstandingRequests.values()].map( + (request) => ({ ...request }), + ), + ...(this.lastResponse && { lastResponse: { ...this.lastResponse } }), + ...(this.notificationStream && { + notificationStream: this.publicStreamState(this.notificationStream), + }), + }; + } + + /** The stream state minus the entry id, which is bookkeeping, not a fact. */ + private publicStreamState( + stream: TrackedNotificationStream, + ): NotificationStreamState { + const { id: _id, ...state } = stream; + return state; + } + + private dispatchConnectionDiagnosticsChange(): void { + this.dispatchTypedEvent( + "connectionDiagnosticsChange", + this.getConnectionDiagnostics(), + ); + } + /** * Get current session ID (from OAuth state authId) */ diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 58aa41f6..f9ce6c8c 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -20,6 +20,7 @@ import type { MessageEntry, StderrLogEntry, FetchRequestEntry, + FetchStreamState, PromptGetInvocation, ResourceReadInvocation, ResourceTemplateReadInvocation, @@ -28,6 +29,7 @@ import type { RequestMetadata, } from "./types.js"; import type { MalformedListItem } from "./listSalvage.js"; +import type { ConnectionDiagnostics } from "./connectionDiagnostics.js"; import type { Tool, ServerCapabilities, @@ -92,6 +94,20 @@ export interface InspectorClientEventMap { fetchRequest: FetchRequestEntry; /** Fired when an in-flight fetch's response body is read asynchronously. */ fetchRequestBodyUpdate: { id: string; responseBody: string }; + /** + * Fired as a tracked long-lived stream (the standalone `GET` on Streamable + * HTTP, the legacy SSE event stream) delivers events and when it ends. The + * body of such a stream is never captured, so this is the log's only view + * of its lifetime (#2318). + */ + fetchRequestStreamUpdate: { id: string; stream: FetchStreamState }; + /** + * Fired whenever the connection diagnostics snapshot changes — a request + * goes out, a response comes back, the notification stream opens, delivers + * an event or closes, or a new session resets it. Carries the fresh + * snapshot; `getConnectionDiagnostics()` returns the same one (#2318). + */ + connectionDiagnosticsChange: ConnectionDiagnostics; /** * Fired whenever the client transitions `status` to `"error"` from a path * that is NOT an awaited promise — i.e. a mid-session transport failure diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 2da4c4e6..8eefd758 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -22,6 +22,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, RequestMetadata, + AppRendererClient, } from "./types.js"; import type { CacheMode, @@ -39,6 +40,7 @@ import type { } from "@modelcontextprotocol/client"; import type { JsonValue } from "../json/jsonUtils.js"; import type { MalformedListItem } from "./listSalvage.js"; +import type { ConnectionDiagnostics } from "./connectionDiagnostics.js"; import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js"; import type { SkillEntry } from "./skillsSchemas.js"; import type { SkillsExtensionSupport } from "./skills.js"; @@ -47,11 +49,11 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; /** - * Opaque type representing the AppRendererClient surface used by @mcp-ui. - * v1.5 aliases this to the SDK `Client` type; v2 leaves it opaque until the - * real InspectorClient (or a focused App-renderer port) lands. + * Re-exported from `./types.js`, where it is defined and documented, so hook + * code that types against this protocol (`core/react/useInspectorClient.ts`) + * can import it here without a second, drift-prone definition (#1745). */ -export type AppRendererClient = unknown; +export type { AppRendererClient } from "./types.js"; /** * The contract every state manager and hook depends on. Anything that holds @@ -77,6 +79,14 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { getDiscoverResult(): DiscoverResult | undefined; getServerSettings(): InspectorServerSettings | undefined; setServerSettings(settings: InspectorServerSettings): void; + /** + * What the client is still waiting on, when it last heard back, and the + * state of the notification stream — the snapshot a request timeout is + * annotated with (#2318). Optional so existing test doubles satisfy the + * interface without implementing it; `connectionDiagnosticsChange` fires + * whenever it changes. + */ + getConnectionDiagnostics?(): ConnectionDiagnostics; getAppRendererClient(): AppRendererClient | null; // Connection control diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index 3402e244..c7645dcc 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -24,6 +24,19 @@ export interface MessageTrackingCallbacks { message: JSONRPCNotification, origin: MessageOrigin, ) => void; + /** + * An outgoing request the base transport failed to send (the connection + * closed, the fetch failed before the frame reached the wire). Fires after + * `trackRequest` already recorded it, so a consumer keeping "requests still + * awaiting a response" can roll that entry back — a request that never went + * out is not unanswered (#2318). Not fired when the caller aborted the + * request through `requestSignal` *during* the send: the modern transport + * aborts an in-flight request's own stream to cancel it (and the SDK does + * the same on a per-request timeout), and a request that was sent and then + * given up on is exactly the unanswered kind. A signal already aborted + * before the send began is a failed send like any other. + */ + trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void; } /** @@ -95,7 +108,19 @@ export class MessageTrackingTransport implements Transport { "client", ); } else if ("method" in message) { - this.callbacks.trackRequest?.(message as JSONRPCRequest, "client"); + const request = message as JSONRPCRequest; + this.callbacks.trackRequest?.(request, "client"); + // A signal already aborted here means the frame will not go out at + // all — that is a failed send. One that becomes aborted while the + // send is in flight is a cancellation of a request that may well + // have reached the server, which stays unanswered. + const abortedBeforeSend = options?.requestSignal?.aborted === true; + return this.baseTransport.send(message, options).catch((err) => { + if (abortedBeforeSend || !options?.requestSignal?.aborted) { + this.callbacks.trackSendFailure?.(request, err); + } + throw err; + }); } } else if ("method" in message) { this.callbacks.trackNotification?.( diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts index 0ce757ba..1ed8e34a 100644 --- a/core/mcp/node/authChallengeFetch.ts +++ b/core/mcp/node/authChallengeFetch.ts @@ -24,8 +24,11 @@ export function createAuthChallengeInterceptFetch( } // Release the connection before throwing so the SDK transport is not left - // with a half-read 401/403 body on streamable HTTP. - await response.body?.cancel().catch(() => {}); + // with a half-read 401/403 body on streamable HTTP. Not awaited: when the + // network tracker sits below this wrapper (#2297) the body is one branch of + // a tee, and cancel() adopts the source's promise, which a stalled body may + // never settle — so awaiting it could hang the throw. + void response.body?.cancel().catch(() => {}); throw new AuthChallengeError( challenge, diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index c90a9d6e..3f23913f 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -8,7 +8,8 @@ import type { SseServerConfig, StreamableHttpServerConfig, } from "../types.js"; -import { normalizeServerType } from "../serverList.js"; +import { isProtocolEra, normalizeServerType } from "../serverList.js"; +import type { ServerProtocolEra } from "../types.js"; import { toRecord } from "../../json/jsonUtils.js"; /** @@ -86,6 +87,20 @@ export function parseHeaderPair( return { ...previous, [key]: val }; } +/** + * Validate a `--protocol-era` value. Used as the Commander option coerce for + * all three clients, so an ad-hoc launch can pick the era without writing an + * `mcp.json` entry for it (#2208). Pure function; no Commander dependency. + */ +export function parseProtocolEra(value: string): ServerProtocolEra { + if (!isProtocolEra(value)) { + throw new Error( + `Invalid protocol era: ${value}. Valid eras are: legacy, auto, modern`, + ); + } + return value; +} + /** On-disk contents of a freshly seeded empty catalog (pretty-printed). */ const EMPTY_CATALOG_CONTENT = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`; diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts index c3e657f4..f8fa91fc 100644 --- a/core/mcp/node/index.ts +++ b/core/mcp/node/index.ts @@ -1,6 +1,7 @@ export { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, withDefaultCatalogPath, resolveServerConfigs, getNamedServerConfigs, diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 2f296c12..8b7a2e34 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -1,7 +1,15 @@ import type { SecretStore } from "../../auth/node/secret-store.js"; import { defaultSecretStore } from "../../auth/node/secret-store-selection.js"; -import type { InspectorServerSettings, MCPServerConfig } from "../types.js"; -import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js"; +import type { + InspectorServerSettings, + MCPServerConfig, + ServerProtocolEra, +} from "../types.js"; +import { + DEFAULT_CONNECTION_TIMEOUT_MS, + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, +} from "../types.js"; import { mcpConfigToServerEntries } from "../serverList.js"; import { applyOverrides, @@ -26,10 +34,14 @@ export type ResolvedServer = { settings?: InspectorServerSettings; }; -/** Loader options: the shared source flags plus a single `--header` set that is - * broadcast into every resolved server's settings. */ +/** Loader options: the shared source flags plus a single `--header` set and an + * optional `--protocol-era`, both broadcast into every resolved server's + * settings. */ export type ServerLoadOptions = ServerConfigOptions & { headers?: Record; + /** `--protocol-era`: overrides the file's `protocolEra` (or the legacy + * default) the way `--header` overrides its headers (#2208). */ + protocolEra?: ServerProtocolEra; /** Test injection; defaults to the selected store (see * `secret-store-selection.ts`) for catalog/config loads. */ secretStore?: SecretStore; @@ -47,10 +59,18 @@ export function headersToServerSettings( return undefined; } return { + ...defaultServerSettings(), headers: Object.entries(headers).map(([key, value]) => ({ key, value })), + }; +} + +/** A settings node with every field at its product default. */ +function defaultServerSettings(): InspectorServerSettings { + return { + headers: [], env: [], metadata: {}, - connectionTimeout: 0, + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, @@ -61,18 +81,29 @@ export function headersToServerSettings( } /** - * Overlay CLI `--header` values onto the settings lifted from the file. Only the - * `headers` field is overridden — timeouts, OAuth, and the rest of the file's - * settings are preserved. + * Overlay `--header` / `--protocol-era` onto the settings lifted from the file + * (or onto nothing, for an ad-hoc target). Only those two fields are overridden + * — timeouts, OAuth, and the rest of the file's settings are preserved. Returns + * `base` untouched when neither flag was given. */ function mergeSettings( base: InspectorServerSettings | undefined, - headers?: Record, + overrides: Pick, ): InspectorServerSettings | undefined { - const fromHeaders = headersToServerSettings(headers); - if (!fromHeaders) return base; - if (!base) return fromHeaders; - return { ...base, headers: fromHeaders.headers }; + let settings = base; + const fromHeaders = headersToServerSettings(overrides.headers); + if (fromHeaders) { + settings = settings + ? { ...settings, headers: fromHeaders.headers } + : fromHeaders; + } + if (overrides.protocolEra) { + settings = { + ...(settings ?? defaultServerSettings()), + protocolEra: overrides.protocolEra, + }; + } + return settings; } /** @@ -113,10 +144,11 @@ export async function loadServerEntries( env: serverOptions.env, cwd: serverOptions.cwd, }), - // Deliberate broadcast: a single `--header` set is merged into EVERY - // server in the catalog/config (fine for the common single-server case; - // for multi-server files, prefer per-server headers in the file itself). - settings: mergeSettings(entry.settings, serverOptions.headers), + // Deliberate broadcast: a single `--header` set (and `--protocol-era`) + // is merged into EVERY server in the catalog/config (fine for the + // common single-server case; for multi-server files, prefer per-server + // settings in the file itself). + settings: mergeSettings(entry.settings, serverOptions), }; } return result; @@ -131,7 +163,7 @@ export async function loadServerEntries( return { default: { config: configs[0]!, - settings: headersToServerSettings(serverOptions.headers), + settings: mergeSettings(undefined, serverOptions), }, }; } diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index dd3c8358..08792f0c 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -49,11 +49,18 @@ export function createTransportNode( pipeStderr = false, onFetchRequest, onFetchResponseBody, + onFetchStreamUpdate, authProvider, settings, interceptAuthChallenges = false, onAuthChallengeObserved, } = options; + // Any of the three tracking callbacks earns a tracker: each is optional on + // the tracker itself, and a caller that wants only stream updates (or only + // bodies) must not be silently handed the bare fetch (#2318). + const wantsFetchTracking = Boolean( + onFetchRequest || onFetchResponseBody || onFetchStreamUpdate, + ); // `optionsFetchFn` is the caller's whole fetch stack and already sits on top of // a proxy-aware base when one is needed — the Node clients install @@ -67,12 +74,29 @@ export function createTransportNode( onAuthChallengeObserved ? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved) : inner; + const withTracking = (inner: typeof fetch): typeof fetch => + wantsFetchTracking + ? createFetchTracker(inner, { + trackRequest: onFetchRequest, + updateResponseBody: onFetchResponseBody, + updateStream: onFetchStreamUpdate, + }) + : inner; // The observer sits *under* the interceptor so it still reports the // challenge on the path where interception throws — and, more to the point, // on the legacy first-auth path where interception is off entirely. + // + // The network tracker sits under the interceptor too (#2297). Above it, the + // tracker never saw the 401/403 at all: the interceptor cancels the body and + // throws, so the entry was recorded from the thrown error alone — no status, + // no `WWW-Authenticate`, and no response body, which is exactly the detail + // someone debugging an OAuth challenge needs. Below it, the tracker records + // the real response and reads its body (bounded) before the interceptor + // cancels it. + const trackedBase = withTracking(withChallengeObserver(baseFetch)); const fetchWithOptionalAuthIntercept = interceptAuthChallenges - ? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch)) - : withChallengeObserver(baseFetch); + ? createAuthChallengeInterceptFetch(trackedBase) + : trackedBase; if (serverType === "stdio") { const stdioConfig = config as StdioServerConfig; @@ -111,15 +135,9 @@ export function createTransportNode( const configuredSseFetch = sseConfig.eventSourceInit?.fetch as | typeof fetch | undefined; - const sseFetch = configuredSseFetch - ? withChallengeObserver(configuredSseFetch) + const trackedFetch = configuredSseFetch + ? withTracking(withChallengeObserver(configuredSseFetch)) : fetchWithOptionalAuthIntercept; - const trackedFetch = onFetchRequest - ? createFetchTracker(sseFetch, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - }) - : sseFetch; const headers = headersFromSettings(settings); @@ -134,18 +152,11 @@ export function createTransportNode( ...(headers && { headers }), }; - const postFetch = onFetchRequest - ? createFetchTracker(fetchWithOptionalAuthIntercept, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - }) - : fetchWithOptionalAuthIntercept; - const transport = new SSEClientTransport(url, { authProvider, eventSourceInit, requestInit, - fetch: postFetch, + fetch: fetchWithOptionalAuthIntercept, }); return { transport }; @@ -161,17 +172,10 @@ export function createTransportNode( ...(headers && { headers }), }; - const transportFetch = onFetchRequest - ? createFetchTracker(fetchWithOptionalAuthIntercept, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - }) - : fetchWithOptionalAuthIntercept; - const transport = new StreamableHTTPClientTransport(url, { authProvider, requestInit, - fetch: transportFetch, + fetch: fetchWithOptionalAuthIntercept, // SEP-2350: how the transport reacts to a `403 insufficient_scope` // challenge. Defaults to the SDK's `reauthorize` when unset. ...(settings?.oauthOnInsufficientScope && { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 4dcbf689..d0757956 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -21,6 +21,12 @@ * the web client against a strict modern server, same as from the CLI/TUI. */ +import { + deadlineForRequestInit, + isOAuthRequestTimeoutWire, + OAuthRequestTimeoutError, +} from "../../auth/requestTimeout.js"; + export interface RemoteFetchOptions { /** Base URL of the remote server (e.g. http://localhost:3000) */ baseUrl: string; @@ -133,14 +139,68 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { reqHeaders["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; } + // #2319: forward the caller's cancellation onto the proxy hop. Without it + // an abort — including the OAuth deadline's own — settled only the caller's + // promise: the POST stayed in flight, so the backend never saw its request + // cancelled and its outbound fetch to the authorization server was left + // running detached, holding a handler and a socket for as long as the + // server cared to stall (Copilot). `init.signal` wins when present and not + // `undefined` (a WebIDL dictionary member present as `undefined` converts + // as absent), otherwise a `Request` carries its own. + const explicitSignal = init?.signal; + const signal = + explicitSignal !== undefined + ? (explicitSignal ?? undefined) + : input instanceof Request + ? input.signal + : undefined; + + // #2319: tell the route whether *this* request is bounded, and by how much. + // The route serves MCP traffic as well as OAuth work, and a Streamable HTTP + // tool call can legitimately withhold its response headers for minutes — so + // a timer there must be per-request rather than unconditional (Copilot). An + // exempt request carries no deadline and the route applies none. + // + // In the JSON envelope rather than a header: `headers` is re-sent verbatim + // to the upstream server, so a marker header would leak to a third party. + const timeoutMs = deadlineForRequestInit(init); + const res = await fetchFn(`${baseUrl}/api/fetch`, { method: "POST", headers: reqHeaders, - body: JSON.stringify({ url, method, headers, body }), + body: JSON.stringify({ + url, + method, + headers, + body, + ...(timeoutMs !== undefined && { timeoutMs }), + }), + signal, }); if (!res.ok) { const text = await res.text(); + // A deadline the backend enforced arrives as an ordinary error response; + // rebuild the typed error from its marker so the `instanceof` checks + // downstream — most importantly the one that lets a stalled probe escape + // `withRfc8414OidcCompat` — still see a timeout for what it is (#2319, + // Copilot). + // + // Both ends run the same budget, so the client's race usually settles + // first and this path is not taken. It matters when the backend's timer + // wins anyway — most plausibly a backgrounded tab, where the browser + // throttles `setTimeout` while the server's fires on schedule. Without + // this, which of two equal deadlines happened to fire would decide + // whether the error carried its endpoint. + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (isOAuthRequestTimeoutWire(parsed)) { + throw new OAuthRequestTimeoutError(parsed.url, parsed.timeoutMs); + } throw new Error(`Remote fetch failed (${res.status}): ${text}`); } diff --git a/core/mcp/remote/createRemoteTransport.ts b/core/mcp/remote/createRemoteTransport.ts index 96803d8b..d93f9b06 100644 --- a/core/mcp/remote/createRemoteTransport.ts +++ b/core/mcp/remote/createRemoteTransport.ts @@ -58,6 +58,7 @@ export function createRemoteTransport( onStderr: transportOptions.onStderr, onFetchRequest: transportOptions.onFetchRequest, onFetchResponseBody: transportOptions.onFetchResponseBody, + onFetchStreamUpdate: transportOptions.onFetchStreamUpdate, authProvider: transportOptions.authProvider, settings: transportOptions.settings, }, diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index fc7c7654..5e5be4e1 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -4,12 +4,13 @@ import type { Transport } from "@modelcontextprotocol/client"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; -import type { FetchRequestEntryBase } from "../../types.js"; +import type { FetchRequestEntryBase, FetchStreamState } from "../../types.js"; import type { RemoteEvent } from "../types.js"; import type { AuthChallenge } from "../../../auth/challenge.js"; import { AuthChallengeError } from "../../../auth/challenge.js"; import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js"; import type { RemoteAuthState } from "../types.js"; +import { progressTokenOf } from "../progressToken.js"; export interface SessionEvent { type: RemoteEvent["type"]; @@ -19,6 +20,14 @@ export interface SessionEvent { type RequestWait = { resolve: () => void; reject: (error: Error) => void; + /** + * Re-arm this wait's timeout for another full window. Called when a + * `notifications/progress` for this request arrives, mirroring the SDK + * client's `resetTimeoutOnProgress` so the relay's own deadline never fires + * before the client's progress-aware one (#2028). A no-op when the wait was + * created without a timeout. + */ + resetTimeout: () => void; }; export class RemoteSession { @@ -172,35 +181,57 @@ export class RemoteSession { timeoutMs = 60_000, ): Promise { return new Promise((resolve, reject) => { - const timer = - timeoutMs > 0 - ? setTimeout(() => { - this.requestWaits.delete(requestId); - reject( - new Error( - `MCP request ${String(requestId)} timed out after ${timeoutMs}ms`, - ), - ); - }, timeoutMs) - : undefined; + let timer: ReturnType | undefined; + const arm = () => { + if (timeoutMs <= 0) return; + timer = setTimeout(() => { + this.requestWaits.delete(requestId); + reject( + new Error( + `MCP request ${String(requestId)} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }; + const clear = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + arm(); this.requestWaits.set(requestId, { resolve: () => { - if (timer !== undefined) { - clearTimeout(timer); - } + clear(); resolve(); }, reject: (error) => { - if (timer !== undefined) { - clearTimeout(timer); - } + clear(); reject(error); }, + // A progress notification for this request re-arms the full window, so + // a tool that keeps reporting progress never trips the relay's own + // deadline — the same guarantee the SDK client's `resetTimeoutOnProgress` + // gives on a direct transport (#2028). + resetTimeout: () => { + if (timer === undefined) return; + clear(); + arm(); + }, }); }); } + /** + * Re-arm a pending request's wait timeout. Invoked from {@link onMessage} when + * a `notifications/progress` carrying this request's `progressToken` arrives. + * A no-op when no wait is pending for the id. + */ + noteRequestProgress(requestId: string | number): void { + this.requestWaits.get(requestId)?.resetTimeout(); + } + cancelRequestWait(requestId: string | number): void { const wait = this.requestWaits.get(requestId); if (!wait) { @@ -265,6 +296,15 @@ export class RemoteSession { ) { this.settleRequestWait(message.id); } + // A `notifications/progress` carries the originating request's id in + // `params.progressToken` (the SDK stamps `progressToken: messageId`). Re-arm + // that request's wait so the relay's deadline tracks the client's + // progress-aware one instead of firing at a flat 60s (#2028). Log messages + // (`notifications/message`) deliberately do NOT reset it, matching the SDK. + const progressToken = progressTokenOf(message); + if (progressToken !== undefined) { + this.noteRequestProgress(progressToken); + } this.pushEvent({ type: "message", data: message }); } @@ -288,6 +328,17 @@ export class RemoteSession { }); } + onFetchStreamUpdate(id: string, stream: FetchStreamState): void { + this.pushEvent({ + type: "fetch_stream_update", + data: { + id, + eventCount: stream.eventCount, + ...(stream.closedAt && { closedAt: stream.closedAt.toISOString() }), + }, + }); + } + onStderr(entry: { timestamp: Date; message: string }): void { this.pushEvent({ type: "stdio_log", diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 69439f6d..5257aec1 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -28,6 +28,8 @@ import { bodyLimit } from "hono/body-limit"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; import { createProxyFetch } from "../../node/proxyFetch.js"; +import { OAUTH_TIMEOUT_WIRE_CODE } from "../../../auth/requestTimeout.js"; +import { redactUrlQuery } from "../../fetchTracking.js"; import type { RemoteConnectRequest, RemoteSendRequest, @@ -63,6 +65,7 @@ import { stripInspectorFields, } from "../../serverList.js"; import { toRecord } from "../../../json/jsonUtils.js"; +import { isSkillCatalogLimit } from "../../skills.js"; import { resolveImportSource } from "../../import/resolveSource.js"; import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; @@ -866,6 +869,8 @@ export function createRemoteApp( onFetchRequest: (entry) => session.onFetchRequest(entry), onFetchResponseBody: (id, body) => session.onFetchResponseBody(id, body), + onFetchStreamUpdate: (id, stream) => + session.onFetchStreamUpdate(id, stream), authProvider: authHandle?.provider, settings: body.settings, // Always intercept 401/403 on the node MCP transport. Without this, the @@ -1204,6 +1209,8 @@ export function createRemoteApp( method?: string; headers?: Record; body?: string; + /** Per-request deadline, set only by a bounded OAuth call (#2319). */ + timeoutMs?: number; }; try { body = (await c.req.json()) as typeof body; @@ -1211,11 +1218,75 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { url, method = "GET", headers = {}, body: reqBody } = body; + const { + url, + method = "GET", + headers = {}, + body: reqBody, + timeoutMs: requestedTimeoutMs, + } = body; if (!url) { return c.json({ error: "Missing url" }, 400); } + // #2319: bound the outbound call, and release it when the browser gives up. + // This hop is where a client-side deadline used to stop being enforceable: + // the browser abandons its promise, but the request it made is served here, + // and an outbound fetch with neither a signal nor a timeout holds a handler + // and an authorization-server socket open indefinitely — once per timed-out + // retry (Copilot). `c.req.raw.signal` is the propagated cancellation, now + // that `createRemoteFetch` forwards it; the timer is the backstop for a + // client that vanishes without aborting, and it matches the client-side + // budget so the two agree on when this is hopeless. + const controller = new AbortController(); + // Set by the timer below and read in the `catch`. The fetch rejects with + // the signal's abort reason, but which of several aborts won is not worth + // inferring from the error — the flag says it directly, the same + // normalization `withOAuthRequestTimeout` uses. + let deadlineFired = false; + const clientSignal: AbortSignal | undefined = c.req.raw.signal; + const signal = clientSignal + ? AbortSignal.any([controller.signal, clientSignal]) + : controller.signal; + // Cleared in `finally`, before the handler returns. Safe because by then + // the body has been either read or explicitly cancelled — this route never + // hands a live stream to its caller, so there is nothing left for the timer + // to protect and nothing it could sever. + // ⚠️ Only the caller decides whether this request is bounded, and by how + // much. This route is the browser's way out to the network for MCP traffic + // as well as OAuth work, so an unconditional timer here would abort a + // Streamable HTTP tool call that legitimately withholds its response + // headers for longer than the budget — reintroducing on the backend exactly + // the regression `exemptMcpEndpoint` exists to prevent on the client, and + // reporting it as an OAuth timeout besides (Copilot). No deadline in the + // envelope means no timer at all. + // Clamped to what `setTimeout` can schedule: past 2**31-1 the delay + // overflows and Node falls back to 1ms, turning an over-large budget into + // an immediate timeout. Non-finite is ignored outright rather than + // defaulted — an envelope carrying `NaN` is a malformed request, and the + // safe reading of a malformed deadline is "no deadline". + const deadlineMs = + typeof requestedTimeoutMs === "number" && + Number.isFinite(requestedTimeoutMs) + ? Math.min(2_147_483_647, Math.max(0, Math.round(requestedTimeoutMs))) + : undefined; + // Redacted for the same reason `OAuthRequestTimeoutError` redacts: this + // message and this URL are handed back to the browser, recorded in the + // Network log and persisted, and an OAuth endpoint's query string can carry + // a `code`, an `access_token` or a `client_secret` (Copilot). + const safeUrl = redactUrlQuery(url); + const timer = + deadlineMs === undefined + ? undefined + : setTimeout(() => { + deadlineFired = true; + controller.abort( + new Error( + `proxied request to ${safeUrl} timed out after ${deadlineMs}ms`, + ), + ); + }, deadlineMs); + try { // Proxy-aware, not the bare global. This route is the browser's ONLY way // out to the network: the web client's `environment.fetch` is @@ -1228,6 +1299,7 @@ export function createRemoteApp( method, headers: new Headers(headers), body: reqBody, + signal, }); const resHeaders: Record = {}; @@ -1242,6 +1314,26 @@ export function createRemoteApp( let resBody: string | undefined; if (!isStream && res.body) { resBody = await res.text(); + } else if (isStream) { + // This route does not return the stream — it answers with JSON and no + // body — so nobody downstream owns it and nothing will ever read it. + // Left un-cancelled, an OAuth endpoint that sends event-stream or + // NDJSON headers and never closes would hold the upstream socket open + // for good, since the `finally` below clears the only deadline once + // this handler returns (Copilot). Best-effort, like `releaseBody` in + // `oidcDiscoveryCompat`: a body already consumed, locked or absent is + // not an error here. Measured, undici does reclaim an unread body on + // its own in this configuration — so this is belt and braces rather + // than a demonstrated leak, and it is kept because relying on that is + // an implementation detail of the fetch beneath us, not a contract. + // + // ⚠️ Started, not awaited. `ReadableStream.cancel()` adopts the + // underlying source's cancel promise, which is permitted never to + // settle — awaiting it would keep this handler pending forever, and on + // an unbounded request there is no timer to release it either + // (Copilot). The `void` is the documented case where the callee owns + // its failures, via the `catch` below, and the caller cannot await. + void res.body?.cancel().catch(() => {}); } return c.json({ @@ -1253,7 +1345,24 @@ export function createRemoteApp( }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); + if (deadlineFired) { + // Stamped so the browser can rebuild an `OAuthRequestTimeoutError` + // rather than receiving a plain `Error` that no `instanceof` check + // downstream can recognize (Copilot). 504 because that is what this + // is — the gateway gave up on the upstream, not a fault in the route. + return c.json( + { + error: msg, + code: OAUTH_TIMEOUT_WIRE_CODE, + url: safeUrl, + timeoutMs: deadlineMs, + }, + 504, + ); + } return c.json({ error: msg }, 500); + } finally { + if (timer !== undefined) clearTimeout(timer); } }); @@ -1575,6 +1684,19 @@ export function createRemoteApp( ); delete valObj.maxFetchRequests; } + // #2294 — positive integers only; `0` is not "unlimited" for these. + for (const key of [ + "skillCatalogMaxSkills", + "skillCatalogMaxBytes", + ] as const) { + if (key in valObj && !isSkillCatalogLimit(valObj[key])) { + logWarn( + { route: "/api/servers", id, droppedKey: key }, + `Dropping malformed \`${key}\` field — expected a positive integer.`, + ); + delete valObj[key]; + } + } if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, @@ -1847,6 +1969,18 @@ export function createRemoteApp( error: "settings.maxFetchRequests must be a non-negative number", }; } + // #2294 — optional on the wire; absent means the default budget. + for (const key of [ + "skillCatalogMaxSkills", + "skillCatalogMaxBytes", + ] as const) { + if (obj[key] !== undefined && !isSkillCatalogLimit(obj[key])) { + return { + ok: false, + error: `settings.${key} must be a positive integer`, + }; + } + } for (const optional of [ "oauthClientId", "oauthClientSecret", @@ -1993,6 +2127,13 @@ export function createRemoteApp( // writes no spurious `roots` field. roots: isRootArray(obj.roots) ? obj.roots : [], }; + // Validated above; absent stays absent (the default budget). + if (isSkillCatalogLimit(obj.skillCatalogMaxSkills)) { + value.skillCatalogMaxSkills = obj.skillCatalogMaxSkills; + } + if (isSkillCatalogLimit(obj.skillCatalogMaxBytes)) { + value.skillCatalogMaxBytes = obj.skillCatalogMaxBytes; + } if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") { value.oauthClientId = obj.oauthClientId; } diff --git a/core/mcp/remote/progressToken.ts b/core/mcp/remote/progressToken.ts new file mode 100644 index 00000000..2ce37b1a --- /dev/null +++ b/core/mcp/remote/progressToken.ts @@ -0,0 +1,40 @@ +/** + * Shared helper for the remote relay's progress-aware request-response waits + * (#2028). Pure and browser-safe, so both the browser `RemoteClientTransport` + * and the Node `RemoteSession` import it rather than each carrying a copy. + */ + +import { ProgressTokenSchema } from "@modelcontextprotocol/core"; +import type { JSONRPCMessage } from "@modelcontextprotocol/client"; + +/** + * The token identifying the request a `notifications/progress` belongs to, or + * `undefined` for any other message. The SDK stamps `progressToken: messageId` + * on the outbound request, so a progress notification's `progressToken` is the + * id of the request whose wait should be re-armed. + * + * Validation is delegated to the SDK's own `ProgressTokenSchema` rather than a + * hand-rolled `typeof` check — the protocol allows only a string or a *safe* + * integer, and zod's `.int()` rejects a fractional or past-`MAX_SAFE_INTEGER` + * value that a bare `typeof token === "number"` would wave through (the same + * drift `isProgressToken` in `core/mcp/inspectorClient.ts` exists to avoid). + */ +export function progressTokenOf( + message: JSONRPCMessage, +): string | number | undefined { + if ( + "method" in message && + message.method === "notifications/progress" && + "params" in message && + typeof message.params === "object" && + message.params !== null + ) { + const parsed = ProgressTokenSchema.safeParse( + (message.params as { progressToken?: unknown }).progressToken, + ); + if (parsed.success) { + return parsed.data; + } + } + return undefined; +} diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 9cd22460..30dff297 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -11,8 +11,10 @@ import type { JSONRPCMessage, MessageExtraInfo, } from "@modelcontextprotocol/client"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { findHeader, isLongLivedStreamResponse } from "../fetchTracking.js"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; -import type { FetchRequestEntryBase } from "../types.js"; +import type { FetchRequestEntryBase, FetchStreamState } from "../types.js"; import type { AuthChallenge, AuthChallengeOutcome, @@ -31,6 +33,7 @@ import type { RemoteSendResponse, } from "./types.js"; import { oauthTokensToRemoteAuthState } from "./types.js"; +import { progressTokenOf } from "./progressToken.js"; export interface AuthRecoveryHandlers { handleAuthChallenge( @@ -60,6 +63,9 @@ export interface RemoteTransportOptions { /** Callback for async response-body updates to a previously tracked fetch. */ onFetchResponseBody?: (id: string, responseBody: string) => void; + /** Callback for a previously tracked long-lived stream's events and close (#2318). */ + onFetchStreamUpdate?: (id: string, stream: FetchStreamState) => void; + /** Optional OAuth client provider for Bearer authentication */ authProvider?: import("@modelcontextprotocol/client").OAuthClientProvider; @@ -85,6 +91,12 @@ const DEFAULT_SSE_RESPONSE_TIMEOUT_MS = 60_000; type SseResponseWait = { resolve: () => void; reject: (error: Error) => void; + /** + * Re-arm this wait's timeout for another full window when a + * `notifications/progress` for the request arrives, mirroring the SDK + * client's `resetTimeoutOnProgress` (#2028). + */ + resetTimeout: () => void; }; function requestIdForMessage( @@ -254,6 +266,14 @@ export class RemoteClientTransport implements Transport { private eventStreamConsumeTask: Promise | null = null; private restartingEventStream = false; private closed = false; + /** + * Long-lived streams the backend has reported open, by fetch entry id, with + * the last event count seen for each. `close()` reports them closed + * itself: it aborts the event channel before asking the backend to + * disconnect, so the backend watcher's own close report lands on an + * unconsumed queue and is lost with the session (#2318). + */ + private openStreams = new Map(); private readonly sseResponseWaits = new Map< string | number, SseResponseWait @@ -524,13 +544,31 @@ export class RemoteClientTransport implements Transport { if (parsed.type === "message") { const msg = parsed.data as JSONRPCMessage; this.settleSseResponseWait(msg); + // Keep the SSE wait alive while progress flows for a long call + // (#2028) — the settle above never matches a notification, so this + // is the only place a progress note touches the wait. + const progressToken = progressTokenOf(msg); + if (progressToken !== undefined) { + this.resetSseResponseWait(progressToken); + } this.onmessage?.(msg, undefined); - } else if ( - parsed.type === "fetch_request" && - this.options.onFetchRequest - ) { + } else if (parsed.type === "fetch_request") { const entry = parsed.data; - this.options.onFetchRequest({ + // Stream bookkeeping runs whether or not the consumer logs + // requests: `onFetchStreamUpdate` is documented to work on its + // own, and `close()` needs to know which streams are open. It + // exists only to serve that callback, though — without one the + // set would only ever grow, one entry per reconnect. + if ( + this.options.onFetchStreamUpdate && + isLongLivedStreamResponse( + entry.method, + findHeader(entry.responseHeaders, "content-type"), + ) + ) { + this.openStreams.set(entry.id, 0); + } + this.options.onFetchRequest?.({ ...entry, timestamp: typeof entry.timestamp === "string" @@ -545,6 +583,19 @@ export class RemoteClientTransport implements Transport { parsed.data.id, parsed.data.responseBody, ); + } else if ( + parsed.type === "fetch_stream_update" && + this.options.onFetchStreamUpdate + ) { + const { id, eventCount, closedAt } = parsed.data; + if (closedAt !== undefined) this.openStreams.delete(id); + else if (this.openStreams.has(id)) { + this.openStreams.set(id, eventCount); + } + this.options.onFetchStreamUpdate(id, { + eventCount, + ...(closedAt !== undefined && { closedAt: new Date(closedAt) }), + }); } else if (parsed.type === "stdio_log" && this.options.onStderr) { this.options.onStderr({ timestamp: new Date(parsed.data.timestamp), @@ -675,14 +726,25 @@ export class RemoteClientTransport implements Transport { private waitForSseResponse(requestId: string | number): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.sseResponseWaits.delete(requestId); - reject( - new Error( - `Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`, - ), - ); - }, this.sseResponseTimeoutMs); + let timer: ReturnType; + const arm = () => { + timer = setTimeout(() => { + this.sseResponseWaits.delete(requestId); + // The SDK's own per-request timeout shape, not a plain `Error`: + // this wait can expire before the SDK's timer does (its budget is + // the relay's, not the request's), and the rejection travels up + // through `Client.request`, where `InspectorClient` annotates a + // timeout of exactly this shape with the connection's state + // (#2318). A plain error would pass that decorator untouched and + // reach the user as a bare timeout on the web client alone. + reject( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: this.sseResponseTimeoutMs, + }), + ); + }, this.sseResponseTimeoutMs); + }; + arm(); this.sseResponseWaits.set(requestId, { resolve: () => { clearTimeout(timer); @@ -692,10 +754,22 @@ export class RemoteClientTransport implements Transport { clearTimeout(timer); reject(error); }, + // A progress notification for this request re-arms the full window so + // the browser-side deadline tracks the SDK client's progress-aware one + // rather than firing at a flat 60s (#2028). + resetTimeout: () => { + clearTimeout(timer); + arm(); + }, }); }); } + /** Re-arm a pending request's SSE wait timeout on a matching progress note. */ + private resetSseResponseWait(requestId: string | number): void { + this.sseResponseWaits.get(requestId)?.resetTimeout(); + } + private async postSend( message: JSONRPCMessage, options: TransportSendOptions | undefined, @@ -806,6 +880,13 @@ export class RemoteClientTransport implements Transport { this.closed = true; this.cancelAllSseWaits(new Error("Transport closed")); await this.stopEventStream(); + // The disconnect below ends every stream the backend still holds open, + // and its close reports cannot reach us any more (see `openStreams`). + const closedAt = new Date(); + for (const [id, eventCount] of this.openStreams) { + this.options.onFetchStreamUpdate?.(id, { eventCount, closedAt }); + } + this.openStreams.clear(); if (this._sessionId) { try { diff --git a/core/mcp/remote/sessionStorage.ts b/core/mcp/remote/sessionStorage.ts index 9c24ee59..7d3764fb 100644 --- a/core/mcp/remote/sessionStorage.ts +++ b/core/mcp/remote/sessionStorage.ts @@ -119,7 +119,10 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { const data = (await res.json()) as InspectorClientSessionState; - // Deserialize state (convert ISO strings back to Date objects) + // Deserialize state (convert ISO strings back to Date objects). The + // nested `stream.closedAt` is a Date too, and `JSON.stringify` turned it + // into an ISO string on the way out; left as a string it would fail the + // `getTime()` every consumer of the type performs (#2318). return { ...data, fetchRequests: data.fetchRequests.map((req) => ({ @@ -130,6 +133,9 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { : req.timestamp instanceof Date ? req.timestamp : new Date(req.timestamp), + ...(req.stream?.closedAt !== undefined && { + stream: { ...req.stream, closedAt: new Date(req.stream.closedAt) }, + }), })), }; } diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 9e948401..661f1522 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -137,6 +137,7 @@ export type RemoteEventType = | "message" | "fetch_request" | "fetch_request_body_update" + | "fetch_stream_update" | "stdio_log" | "transport_error" | "auth_challenge"; @@ -156,6 +157,16 @@ export interface RemoteEventFetchRequestBodyUpdate { data: { id: string; responseBody: string }; } +/** + * A long-lived stream's lifecycle, forwarded from the backend's fetch tracker + * (#2318). `closedAt` is an ISO string on the wire; the browser transport + * rebuilds the `Date` the way it does for `fetch_request`'s timestamp. + */ +export interface RemoteEventFetchStreamUpdate { + type: "fetch_stream_update"; + data: { id: string; eventCount: number; closedAt?: string }; +} + export interface RemoteEventStdioLog { type: "stdio_log"; data: { timestamp: string; message: string }; @@ -178,6 +189,7 @@ export type RemoteEvent = | RemoteEventMessage | RemoteEventFetchRequest | RemoteEventFetchRequestBodyUpdate + | RemoteEventFetchStreamUpdate | RemoteEventStdioLog | RemoteEventTransportError | RemoteEventAuthChallenge; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 220253d5..a0b42700 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -6,12 +6,18 @@ */ import { + DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_MODERN_LOG_LEVEL, DEFAULT_PROTOCOL_ERA, DEFAULT_TASK_TTL_MS, isModernLogLevel, } from "./types.js"; +import { + isSkillCatalogLimit, + SKILL_MAX_CATALOG_BYTES, + SKILL_MAX_CATALOG_SKILLS, +} from "./skills.js"; import type { Root } from "@modelcontextprotocol/client"; import type { InspectorServerSettings, @@ -159,6 +165,8 @@ type StoredInspectorFields = Pick< | "paginatedLists" | "advertisedExtensions" | "maxFetchRequests" + | "skillCatalogMaxSkills" + | "skillCatalogMaxBytes" | "oauth" | "roots" >; @@ -490,8 +498,11 @@ export function oauthEndpointOverridesFromSettings( * * `headers` becomes a pair-array preserving the object's key insertion order; * `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` / - * `oauthScopes` fields. Numeric timeouts default to 0 when absent — the form - * needs concrete values to render and 0 is the SDK's "no timeout" signal. + * `oauthScopes` fields. The numeric timeouts get concrete values when absent + * because the form needs them to render: `connectionTimeout` reads back as + * `DEFAULT_CONNECTION_TIMEOUT_MS` (30 s), while `requestTimeout` reads back as + * 0, the SDK's "use your own default" signal. An explicit `connectionTimeout` + * of 0 is preserved — it is the user's "no timeout" opt-out (#2320). * * `env` / `cwd` are SDK config fields (not Inspector-extension keys), but they * are mirrored into the settings here so the Server Settings modal can edit @@ -517,6 +528,8 @@ export function storedFieldsToInspectorSettings( stored.paginatedLists !== undefined || stored.advertisedExtensions !== undefined || stored.maxFetchRequests !== undefined || + stored.skillCatalogMaxSkills !== undefined || + stored.skillCatalogMaxBytes !== undefined || stored.oauth !== undefined || stored.roots !== undefined || stored.protocolEra !== undefined || @@ -533,9 +546,13 @@ export function storedFieldsToInspectorSettings( headers: headersPairs, env: envRecordToPairs(stored.env), metadata: normalizeStoredMetadata(stored.metadata), - connectionTimeout: stored.connectionTimeout ?? 0, + // Concrete product default, like taskTtl below: an absent field reads back + // as 30 s so the form shows the bound that will actually apply. An explicit + // 0 is preserved as-is — it is the user's opt-out, not a sentinel (#2320). + connectionTimeout: + stored.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS, requestTimeout: stored.requestTimeout ?? 0, - // Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product + // Unlike requestTimeout (0 = "SDK default"), task TTL has a concrete product // default so the form shows it and "Run as task" has a value to send. taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: stored.autoRefreshOnListChanged ?? false, @@ -549,6 +566,16 @@ export function storedFieldsToInspectorSettings( // `[]`, which `inspectorSettingsToStoredFields` then omits on write. roots: stored.roots ?? [], }; + // Lifted only when usable (#2294). Unlike maxFetchRequests these stay + // optional in memory — absent means the default, which + // `resolveSkillCatalogBudget` supplies — and a hand-edited `0` or `-1` is + // dropped here rather than carried into a form that would re-persist it. + if (isSkillCatalogLimit(stored.skillCatalogMaxSkills)) { + settings.skillCatalogMaxSkills = stored.skillCatalogMaxSkills; + } + if (isSkillCatalogLimit(stored.skillCatalogMaxBytes)) { + settings.skillCatalogMaxBytes = stored.skillCatalogMaxBytes; + } // Absent on disk reads back as the default era; the write side then omits the // default so a byte-stable round-trip never injects `protocolEra` into files // that never set it. An unknown literal from a hand-edited file is dropped @@ -632,8 +659,10 @@ export function storedFieldsToInspectorSettings( * Splat the form-shape `InspectorServerSettings` back into the on-disk * Inspector-extension fields (object-form `headers`, nested `oauth`, etc.). * Empty-key rows are dropped — the form lets users leave new rows blank - * mid-edit and those shouldn't reach disk. Numeric timeouts at 0 are omitted - * so the file diff stays minimal for entries that never touched them. + * mid-edit and those shouldn't reach disk. Timeouts at their default are + * omitted so the file diff stays minimal for entries that never touched them: + * that is `DEFAULT_CONNECTION_TIMEOUT_MS` for `connectionTimeout` (an explicit + * 0 is a chosen opt-out and is written) and 0 for `requestTimeout`. * * Returns the field deltas to merge onto a `StoredMCPServer`; callers can * spread the result. @@ -659,7 +688,11 @@ export function inspectorSettingsToStoredFields( out.metadata = settings.metadata; } - if (settings.connectionTimeout > 0) { + // The product default is the omit-sentinel (as for taskTtl below), so a file + // that never named a timeout stays byte-stable. Anything else persists — + // including 0, which is now a non-default the user chose (no timeout) and + // would otherwise silently read back as 30 s on the next load (#2320). + if (settings.connectionTimeout !== DEFAULT_CONNECTION_TIMEOUT_MS) { out.connectionTimeout = settings.connectionTimeout; } if (settings.requestTimeout > 0) { @@ -714,14 +747,31 @@ export function inspectorSettingsToStoredFields( out.modernLogLevel = settings.modernLogLevel; } - // Persist only when it differs from the default. Unlike the timeouts, 0 is a - // meaningful value here (unlimited), so the omit-sentinel is the default - // itself rather than 0 — writing the default would inject the field into - // hand-edited files that never had it and break byte-stable round-trips. + // Persist only when it differs from the default. Like connectionTimeout and + // unlike requestTimeout, 0 is a meaningful value here (unlimited), so the + // omit-sentinel is the default itself rather than 0 — writing the default + // would inject the field into hand-edited files that never had it and break + // byte-stable round-trips. if (settings.maxFetchRequests !== DEFAULT_MAX_FETCH_REQUESTS) { out.maxFetchRequests = settings.maxFetchRequests; } + // Same omit-the-default rule (#2294). An unusable value writes nothing, so + // it reads back as the default rather than being persisted and then + // rejected on the next load. + if ( + isSkillCatalogLimit(settings.skillCatalogMaxSkills) && + settings.skillCatalogMaxSkills !== SKILL_MAX_CATALOG_SKILLS + ) { + out.skillCatalogMaxSkills = settings.skillCatalogMaxSkills; + } + if ( + isSkillCatalogLimit(settings.skillCatalogMaxBytes) && + settings.skillCatalogMaxBytes !== SKILL_MAX_CATALOG_BYTES + ) { + out.skillCatalogMaxBytes = settings.skillCatalogMaxBytes; + } + const oauthFields: NonNullable = {}; if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId; if (settings.oauthClientSecret) @@ -800,6 +850,8 @@ const INSPECTOR_FIELD_KEY_MAP = { paginatedLists: true, advertisedExtensions: true, maxFetchRequests: true, + skillCatalogMaxSkills: true, + skillCatalogMaxBytes: true, oauth: true, roots: true, } as const satisfies Record; diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 61dd7593..cacb8f9f 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -11,12 +11,15 @@ * shared by every client, and produce a structured finding list rather than a * boolean — a report is useful, "invalid" is not. * - * ⚠️ Skills is a **server-declared** extension, read off the connecting server's - * `capabilities.extensions`. It deliberately does NOT belong in - * `ADVERTISABLE_EXTENSIONS` (`core/mcp/extensions.ts`), which is the catalog of - * extensions the *Inspector* advertises and the user toggles in Server Settings. - * The precedent is `appElicitation.ts`, which reads the server side the same - * way; getting it backwards would put a meaningless toggle in Server Settings. + * ⚠️ Skills is negotiated from **both** sides (SEP-2133), and the two halves + * live in different places. This module reads the *server's* declaration off + * the connecting server's `capabilities.extensions` — that is what gates the + * Skills screen. The *Inspector's* own declaration is a separate entry in + * `ADVERTISABLE_EXTENSIONS` (`core/mcp/extensions.ts`): a strict server refuses + * `skills/*` to a client that did not declare it, and turning that toggle off + * in Server Settings is how to check a server's refusal path (#2373). An + * earlier revision of this comment said Skills must never be in that registry; + * that was the missing client declaration #2373 fixed. * * The Inspector is an inspector, not a host: a `resources/read` of a `SKILL.md` * is explicitly not a load and confers no standing, so none of the SEP's host @@ -76,6 +79,47 @@ export const SKILL_MAX_CATALOG_SKILLS = 256; /** @see {@link SKILL_MAX_CATALOG_SKILLS} — 64 MiB across the whole run. */ export const SKILL_MAX_CATALOG_BYTES = 64 * 1024 * 1024; +/** + * Whether `value` is usable as a per-server catalog budget override (#2294): + * a positive safe integer. + * + * ⚠️ **`0` is deliberately not "unlimited"**, unlike `maxFetchRequests`. The + * budget exists so a `--verify` against a huge catalog terminates; an + * unlimited setting would reintroduce exactly the unbounded run it closed. A + * user who wants more raises the number. + */ +export function isSkillCatalogLimit(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +/** A verification run's catalog budget, resolved from per-server settings. */ +export interface SkillCatalogBudget { + maxSkills: number; + maxBytes: number; +} + +/** + * The catalog budget for a run: the server's configured limits where they are + * usable, else {@link SKILL_MAX_CATALOG_SKILLS} / {@link SKILL_MAX_CATALOG_BYTES}. + * + * A malformed value falls back to the default rather than throwing — settings + * can arrive from a hand-edited `mcp.json`, and a verification that refused to + * run over a typo would be a worse outcome than one that ran at the default. + */ +export function resolveSkillCatalogBudget(settings?: { + skillCatalogMaxSkills?: number; + skillCatalogMaxBytes?: number; +}): SkillCatalogBudget { + return { + maxSkills: isSkillCatalogLimit(settings?.skillCatalogMaxSkills) + ? settings.skillCatalogMaxSkills + : SKILL_MAX_CATALOG_SKILLS, + maxBytes: isSkillCatalogLimit(settings?.skillCatalogMaxBytes) + ? settings.skillCatalogMaxBytes + : SKILL_MAX_CATALOG_BYTES, + }; +} + /** The suffix every skill URI ends with; the segment before it is the name. */ export const SKILL_FILE_SUFFIX = "/SKILL.md"; @@ -171,6 +215,17 @@ export function isSkillsExtensionSupported( * rejected too: containment could not be decided on it, and silently accepting * one would reintroduce exactly the hole this function closes. * + * A **path-less authority** URI (`skill://data-analysis`) is accepted, and is + * not the opaque case above: RFC 3986 gives a URI with an authority an empty + * path, and the parser reports that as `pathname === ""`. The authority has to + * be non-empty — `skill:` parses to an empty path too, with no host. It has no segments, + * so nothing survives unnormalized. It is what a skill's root directory looks + * like once `/SKILL.md` is removed, so rejecting it made a server echoing that + * root back in a directory read read as "outside this skill" when it is the + * skill itself (#2295). It names no file, so it still fails every check that + * needs one — `skillNameFromUri` wants the `/SKILL.md` suffix, and a manifest + * entry spelled this way does not start with `/`. + * * The scheme is deliberately **not** constrained. `skill://` is what SEP-2640 * recommends and what this repo's fixture serves, but the SEP only says servers * SHOULD use it and explicitly allows a domain-native scheme (`github://…`), so @@ -186,7 +241,10 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } - if (!parsed.pathname.startsWith("/")) return undefined; + // An empty path is only the root form when an authority carries the identity: + // `skill:` and `mailto:` also parse to `pathname === ""`, with no host. + const pathlessRoot = parsed.pathname === "" && parsed.host !== ""; + if (!pathlessRoot && !parsed.pathname.startsWith("/")) return undefined; return canonicalizePercentEncoding(parsed.href); } diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts index 79e76341..2904c408 100644 --- a/core/mcp/skillsSchemas.ts +++ b/core/mcp/skillsSchemas.ts @@ -131,9 +131,18 @@ export type ListSkillsResult = z.infer; * * ⚠️ Picked by `InspectorClient.listSkills` from the negotiated era — a schema * cannot know it on its own. + * + * ⚠️ **No `resultType` member, and that is not an omission (#2373).** The + * SDK's 2026-07-28 codec checks `resultType` on every result — missing is + * `InvalidResult`, anything but `"complete"` is `UnsupportedResultType` — and + * then **deletes it** before the result reaches the caller's schema. Requiring + * it here therefore rejected every conforming modern page, and did so unseen + * only because the integration suite's "modern" cases were connecting on + * legacy. The caching attributes are different: the codec neither checks nor + * lifts them for a consumer-owned method, so this schema is still the only + * thing that does. */ export const ModernListSkillsResultSchema = ListSkillsResultSchema.extend({ - resultType: z.literal("complete"), // A non-negative INTEGER, matching the codec: `ttlMs: -1` or `0.5` is an // envelope violation, and accepting it here would hide exactly the kind of // defect this schema exists to surface. @@ -172,7 +181,10 @@ export const GetSkillEnvelopeSchema = z.looseObject({ }); /** - * The `skills/get` result as the server sent it, envelope and all. + * The `skills/get` result envelope as the SDK decoded it: the `skill` wrapper + * plus any caching attributes the server sent. On a modern connection + * `resultType` is not in it — the codec checks it and removes it before this + * schema runs (#2373). * * Exported alongside the unwrapping schema below because the two callers want * different things: the UIs want the entry, while the CLI's job is to print @@ -183,21 +195,6 @@ export const GetSkillEnvelopeSchema = z.looseObject({ */ export type GetSkillEnvelope = z.infer; -/** - * `skills/get` on a **modern** (2026-07-28+) connection: the envelope plus - * `resultType`, and deliberately still not the caching attributes. - * - * The "left open" quote above covers `ttlMs` / `cacheScope` and **only** those. - * `resultType` is a different thing: SEP-2322 makes it a member of every modern - * result, and SEP-2640's own `skills/get` example carries - * `"resultType": "complete"`. Leaving it optional here while requiring it of - * `resources/directory/read` was an inconsistency in this module rather than a - * distinction the spec draws (Copilot). - */ -export const ModernGetSkillEnvelopeSchema = GetSkillEnvelopeSchema.extend({ - resultType: z.literal("complete"), -}); - /** * `skills/get` result, unwrapped to the entry it carries. * @@ -238,8 +235,10 @@ export type GetSkillResult = SkillEntry; export const DirectoryChildSchema = ResourceSchema; /** - * `resources/directory/read` result on a **legacy** connection: the directory's - * direct children plus the opaque cursor. + * `resources/directory/read` result on **either** era: the directory's direct + * children plus the opaque cursor. On a modern connection this is the shape + * after the SDK codec has checked and removed `resultType`, which is why no + * modern variant exists — see the note below (#2373). * * `looseObject`, matching every other result schema here: a server that also * sends the caching attributes is not wrong for doing so, and a schema is not @@ -252,29 +251,28 @@ export const DirectoryReadResultSchema = z.looseObject({ export type DirectoryReadResult = z.infer; -/** - * `resources/directory/read` result on a **modern** (2026-07-28+) connection: - * the page plus `resultType`, and deliberately **not** `ttlMs` / `cacheScope`. +/* + * There is deliberately **no modern variant** of the `resources/directory/read` + * or `skills/get` schemas, though `skills/list` has one (#2373). * - * That asymmetry with {@link ModernListSkillsResultSchema} is the one judgement - * call in this module, so it is written down rather than left to be re-derived: + * On a modern (2026-07-28+) connection the SDK codec already enforces + * `resultType` — the base-protocol member SEP-2322 puts on every modern result + * — and lifts it off before any caller schema runs. What remains is the + * caching attributes, and only `skills/list` is required to carry those: * - * - For `skills/list` the SEP states the requirement outright — *"In protocol - * versions 2026-07-28 and later, the result also carries … `ttlMs` and - * `cacheScope`"* — so requiring them is quoting the spec. + * - For `skills/list` the SEP states it outright — *"In protocol versions + * 2026-07-28 and later, the result also carries … `ttlMs` and + * `cacheScope`"* — so {@link ModernListSkillsResultSchema} requiring them is + * quoting the spec. * - For `resources/directory/read` it states **nothing of the kind**, and its - * one worked example of the result carries `resultType: "complete"` and no - * caching attributes at all. Requiring them here would fail a server that - * matched the SEP's own example, which is the failure direction this module - * works hardest to avoid. + * one worked example carries `resultType: "complete"` and no caching + * attributes at all. Requiring them would fail a server that matched the + * SEP's own example, the failure direction this module works hardest to + * avoid. + * - For `skills/get` the SEP leaves the question open in as many words (see + * {@link GetSkillEnvelopeSchema}). * - * `resultType` is required because it is the base protocol's, not this - * extension's: SEP-2322 makes it a member of every modern result, the SEP's - * example carries it, and `skills/*` being consumer-owned means the SDK codec - * validates none of it — so if this schema does not, nothing does. - * - * ⚠️ Picked by `InspectorClient.readResourceDirectory` from the negotiated era. + * So both eras parse those two results with one schema each. An earlier + * revision required `resultType` on "modern" variants of both; since the codec + * lifts it, they could never pass on a real modern connection. */ -export const ModernDirectoryReadResultSchema = DirectoryReadResultSchema.extend( - { resultType: z.literal("complete") }, -); diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts index c034b465..a3aa63ff 100644 --- a/core/mcp/skillsVerification.ts +++ b/core/mcp/skillsVerification.ts @@ -32,9 +32,8 @@ import { checkSkillConformance, checkSkillFrontmatterMatch, checkSkillNameCollisions, + resolveSkillCatalogBudget, skillDisplayName, - SKILL_MAX_CATALOG_BYTES, - SKILL_MAX_CATALOG_SKILLS, SKILL_MAX_RESOURCE_ENTRIES, SKILL_MAX_TOTAL_BYTES, skillFileBytes, @@ -323,15 +322,27 @@ export async function verifySkills( // thousand skills, each individually conforming, made `--verify` run // indefinitely and transfer unboundedly (Copilot). See // {@link SKILL_MAX_CATALOG_SKILLS}. + // + // The limits are the server's configured ones when set (#2294). Read through + // `getServerSettings()` rather than taken as a parameter, so every caller — + // the CLI's `--verify` and the TUI pane — honors the setting without having + // to remember to pass it. Optional-chained because a test double need not + // implement the accessor. + const budget = resolveSkillCatalogBudget(client.getServerSettings?.()); let walkedSkills = 0; let catalogBytes = 0; for (const entry of entries) { // Static checks still run for every entry — they cost no I/O, so a skill // past the budget is still reported on, just not read. What stops is the // reading. + // + // ⚠️ STRICT on bytes, like the count. A run whose charged bytes exactly + // equal the limit has REACHED it, and `<=` read one more skill past a + // budget that was already spent (Copilot, #2294). The skill whose reads + // cross the limit is still reported in full — that is decided by charging + // after the entry, below, not by this comparison. const withinBudget = - walkedSkills < SKILL_MAX_CATALOG_SKILLS && - catalogBytes <= SKILL_MAX_CATALOG_BYTES; + walkedSkills < budget.maxSkills && catalogBytes < budget.maxBytes; // The entry's own SKILL.md, read once and used twice — for its digest and // for the frontmatter cross-check. Reading it twice would double the load // on the server and, worse, could compare a digest against one snapshot @@ -368,7 +379,7 @@ export async function verifySkills( // total, by definition) is never truncated. const manifest = withinBudget ? boundedManifest(declared) : []; let incomplete = !withinBudget - ? `Not read: this run already reached its catalog budget of ${SKILL_MAX_CATALOG_SKILLS} skills / ${SKILL_MAX_CATALOG_BYTES} bytes. Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri\` to get a verdict.` + ? `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\` 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; @@ -499,6 +510,12 @@ export async function verifySkills( files.push({ uri: entry.uri, status: "read-error", reason }); try { const invocation = await client.readResource(entry.uri, metadata); + // ⚠️ Charged like a manifest read — raw response, or the decoded + // length when larger. Uncharged, a catalog of `"dynamic"` skills (whose + // ONLY read is this one) left `catalogBytes` at zero, so the run's byte + // budget bounded nothing for it and even a configured limit of 1 read + // every skill up to the count limit (Copilot, #2294). + let charged = responseBytes(invocation.result); const contents = contentsFor(invocation.result, entry.uri); if (!contents) { fail( @@ -507,10 +524,12 @@ export async function verifySkills( } else { try { entryBytes = skillFileBytes(contents); + charged = Math.max(charged, entryBytes.byteLength); } catch (err) { fail(reasonOf(err)); } } + receivedBytes += charged; // If the DECLARED manifest lists this file but the read bounds // excluded it, verify it here too. The fallback exists for the // frontmatter check, but reading a file and then not checking the diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index 080fd39b..2e547ad6 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -151,6 +151,28 @@ export class FetchRequestLogState extends TypedEventTarget, + ): void => { + const { id, stream } = event.detail; + const idx = this.fetchRequests.findIndex((e) => e.id === id); + if (idx === -1) return; + this.fetchRequests[idx] = { ...this.fetchRequests[idx]!, stream }; + this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); + }; + this.client.addEventListener( + "fetchRequestStreamUpdate", + onFetchRequestStreamUpdate, + ); + const sessionStorage = options.sessionStorage; const sessionId = options.sessionId; @@ -194,6 +216,10 @@ export class FetchRequestLogState extends TypedEventTarget; @@ -643,6 +683,18 @@ export type OnInsufficientScopePolicy = "reauthorize" | "throw"; */ export const DEFAULT_TASK_TTL_MS = 60000; +/** + * Default connect-time timeout (ms) applied when a server has no explicit + * `connectionTimeout` — on disk, in the form, or on the client. The SDK has no + * connect-time timeout of its own; without this, the only thing bounding a + * connect attempt was the SDK's per-request timeout on `initialize`, which + * fires after 60 s with a message that describes a JSON-RPC request rather + * than a connection (#2320). `0` remains the explicit opt-out ("no timeout"), + * so a stored `connectionTimeout: 0` and the CLI's `--connect-timeout 0` keep + * their documented meaning; only an *absent* value resolves to this. + */ +export const DEFAULT_CONNECTION_TIMEOUT_MS = 30000; + /** * Default maximum number of HTTP fetch requests retained in the Network log * (per server). When exceeded, the oldest entries rotate out. A larger value @@ -781,6 +833,10 @@ export interface InspectorServerSettings { * empty/unset means "inherit". Only meaningful for stdio transports. */ cwd?: string; + /** + * Connect-time timeout (ms). Defaults to `DEFAULT_CONNECTION_TIMEOUT_MS` + * (30000) when the server has no explicit value; `0` means no timeout. + */ connectionTimeout: number; requestTimeout: number; /** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */ @@ -880,6 +936,17 @@ export interface InspectorServerSettings { * render; defaults to `DEFAULT_MAX_FETCH_REQUESTS`. `0` means unlimited. */ maxFetchRequests: number; + /** + * Catalog budget for skills verification (#2294): the most skills, and the + * most bytes, one run reads. Optional rather than concrete like + * `maxFetchRequests` — absent means `SKILL_MAX_CATALOG_SKILLS` / + * `SKILL_MAX_CATALOG_BYTES`, resolved by `resolveSkillCatalogBudget`, and + * the form renders those defaults itself. Read by `verifySkills` through + * `getServerSettings()`, so the CLI's `--verify` and the TUI's Skills pane + * both honor it. + */ + skillCatalogMaxSkills?: number; + skillCatalogMaxBytes?: number; /** * Roots advertised to the server via the `roots` client capability. Each * root carries a required `uri` and an optional `name` (SDK `Root`). The @@ -966,6 +1033,14 @@ export interface CreateTransportOptions { */ onFetchResponseBody?: (id: string, responseBody: string) => void; + /** + * Optional callback fired asynchronously as a previously tracked long-lived + * stream (GET + `text/event-stream`) delivers events and when it ends. The + * tracker never buffers such a body, so this is the only view the consumer + * gets of the stream's lifetime (#2318). + */ + onFetchStreamUpdate?: (id: string, stream: FetchStreamState) => void; + /** * Optional OAuth client provider for Bearer authentication (SSE, streamable-http). * When set, the SDK injects tokens and handles 401 via the provider. @@ -1023,9 +1098,13 @@ export type CreateTransport = ( ) => CreateTransportResult; /** - * Type for the client-like object passed to AppRenderer / @mcp-ui. - * Structurally compatible with the MCP SDK Client but denotes the app-renderer - * proxy, not the raw client. Use this type when passing the client to the Apps tab. + * The SDK `Client` handed to the MCP Apps host bridge (`AppBridge` in + * `@modelcontextprotocol/ext-apps`), obtained through + * `InspectorClient.getAppRendererClient()`, which returns it only while the + * connection status is `connected` and `null` otherwise. It is the real client, + * not a wrapper: since ext-apps 2.0.0 peers on SDK v2 the bridge registers on + * it directly, and the v1-peer translation proxy this alias used to denote is + * gone (#1745). Use this type when passing the client to the Apps tab. */ export type AppRendererClient = Client; diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index 1d22a94c..448144c5 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -12,6 +12,7 @@ import type { } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "../mcp/types.js"; import type { MalformedListItem } from "../mcp/listSalvage.js"; +import type { ConnectionDiagnostics } from "../mcp/connectionDiagnostics.js"; import { useStoreSnapshot } from "./useStoreSnapshot.js"; // Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES` @@ -27,6 +28,13 @@ const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({}); */ const NO_EXCLUDED_TOOLS: ExcludedTool[] = []; const NO_MALFORMED_LIST_ITEMS: MalformedListItem[] = []; +// Also the fallback for a client that does not implement the optional +// accessor (a test double), so the field is always a snapshot and never +// `undefined` — one shape for consumers, whatever client is attached. +const NO_CONNECTION_DIAGNOSTICS: ConnectionDiagnostics = Object.freeze({ + capturedAt: 0, + outstandingRequests: [], +}); const readStatus = (client: InspectorClientProtocol): ConnectionStatus => client.getStatus(); @@ -53,6 +61,10 @@ const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] => const readMalformedListItems = ( client: InspectorClientProtocol, ): MalformedListItem[] => client.getMalformedListItems(); +const readConnectionDiagnostics = ( + client: InspectorClientProtocol, +): ConnectionDiagnostics => + client.getConnectionDiagnostics?.() ?? NO_CONNECTION_DIAGNOSTICS; export interface UseInspectorClientResult { status: ConnectionStatus; @@ -86,6 +98,13 @@ export interface UseInspectorClientResult { * non-empty set means that list rendered without them (#1909). */ malformedListItems: MalformedListItem[]; + /** + * What the client is still waiting on, when it last heard back, and the + * state of the notification stream — re-read on every request, response + * and stream event. The Connection Info modal renders it; a request + * timeout's message is written from the same snapshot (#2318). + */ + connectionDiagnostics: ConnectionDiagnostics; /** * Message from the most recent mid-session transport failure (the client's * `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next @@ -174,6 +193,12 @@ export function useInspectorClient( readMalformedListItems, NO_MALFORMED_LIST_ITEMS, ); + const connectionDiagnostics = useStoreSnapshot( + inspectorClient, + "connectionDiagnosticsChange", + readConnectionDiagnostics, + NO_CONNECTION_DIAGNOSTICS, + ); // `lastError` is the one value here that is NOT a snapshot of something the // client stores: there is no `getLastError()`, because the client emits the @@ -243,6 +268,7 @@ export function useInspectorClient( discoverResult, excludedTools, malformedListItems, + connectionDiagnostics, lastError, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, diff --git a/docs/docker.md b/docs/docker.md index ed93353c..5b06c0f5 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -28,6 +28,8 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278: Publish each on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. The same holds for the app origin: the URL a published app document is served from is built from the port the container bound, so remap with `-e MCP_APP_ORIGIN_PORT=9001 -p 127.0.0.1:9001:9001` rather than with `-p` alone. +**Behind a reverse proxy or an ingress**, where the browser reaches the listeners at public hostnames rather than at `localhost`, the same-number rule is not enough: the advertised URLs name the container's bind. Set `MCP_SANDBOX_FULL_ADDRESS` (e.g. `https://inspector-sandbox.example.com/sandbox`) and, for apps declaring `_meta.ui.domain`, `MCP_APP_ORIGIN_FULL_ADDRESS` (e.g. `https://inspector-apps.example.com`), route each to its container port, and set `ALLOWED_ORIGINS` to the public Inspector origin. Each listener needs its own hostname or port — an address sharing the Inspector's origin is refused. See [Behind a reverse proxy](../clients/web/README.md#host-binding--the-origin-allow-list) for the full contract. + **Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. **Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: @@ -77,7 +79,7 @@ If another process holds the lock and will not let go, the save **fails** rather It is also what covers the lock being unavailable. This store exists for boxes where the usual mechanism isn't there, so a directory that can't hold a lock file — a read-only `$HOME`, a mount owned by another uid — makes the save proceed unlocked with a warning, rather than turning every `set` into a failure on exactly the deployments the store was written for. -Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. +Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. These variables apply outside a container too; every runtime variable is listed in [Environment variables](./environment-variables.md). **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..0c7cf6ce --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,127 @@ +# Environment variables + +Every environment variable that changes how the Inspector behaves at runtime, in one place: the Inspector's own variables, plus the standard system and Node variables it reads (`HOME`, the proxy variables, the Node TLS variables). Set them in the shell that launches `mcp-inspector` (or with `-e` for the [Docker image](./docker.md)). + +The **Read by** column names the client whose process reads the variable: **web** is the Node backend that `--web` starts (the browser itself reads no environment), **CLI** and **TUI** are those clients, and **launcher** is the `mcp-inspector` bin that picks one of them. A variable read in shared `core/` code is marked with every client that reaches it. + +⚠️ **Unset a variable rather than setting it to an empty string.** The two are not interchangeable: `HOST=""` is read as an all-interfaces bind and refused, and an empty path variable such as `MCP_STORAGE_DIR=` or `MCP_INSPECTOR_LOG_DIR=` can resolve relative to the working directory instead of falling back to the default. A row says so explicitly where an empty value is treated as unset. + +A `~` in a default below means the home directory as described under [Home directory](#home-directory). + +## Authentication and network exposure + +These guard the web backend, which spawns processes on request. Read [Host binding and the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list) before widening any of them. + +| Variable | Read by | Default | Effect | +| --------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_INSPECTOR_API_TOKEN` | web, CLI | a random token per launch | Bearer token guarding every `/api/*` route (`x-mcp-remote-auth: Bearer `). Set it to use a known token instead of the generated one printed in the launch banner. The CLI reads it only to fill the `autoConnect` parameter of the deep link it emits. | +| `MCP_PROXY_AUTH_TOKEN` | web | — | **Deprecated** v1 name for `MCP_INSPECTOR_API_TOKEN`, used only when the new name is unset. | +| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | +| `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, an empty string, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | +| `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | +| `ALLOWED_ORIGINS` | web | derived from `HOST` | Comma-separated origins allowed to call the API. Unset, the list follows `HOST` at `CLIENT_PORT`: the loopback origins for a loopback host, the loopback origins plus `http://0.0.0.0` and `http://[::]` for an all-interfaces bind, and otherwise only the configured host's own origin (so binding a LAN address does **not** also allow `localhost`). **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). The same list is the MCP Apps sandbox proxy's embedder allow-list (its `frame-ancestors` header and its referrer check), so a public Inspector origin must be listed here for the Apps tab to render. | + +## Ports + +| Variable | Read by | Default | Effect | +| --------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLIENT_PORT` | web, CLI | `6274` | Web UI port. Must be a **fixed** integer in 1–65535: `0` (an OS-assigned port) is rejected at startup, because the origin allow-list and the MCP Apps sandbox CSP are derived from it. | +| `MCP_SANDBOX_PORT` | web, CLI | `6275` | Port of the MCP Apps sandbox server, 0–65535. `0` asks the OS for a free port. An invalid value is ignored with a warning. | +| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for the sandbox port: used whenever `MCP_SANDBOX_PORT` does not yield a valid port — unset, empty, **or invalid**. | +| `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`; 0–65535, where `0` asks the OS for a free port. An invalid value is ignored with a warning. Pin it if your app's backend allowlists that origin. | + +The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. It normalizes `HOST` (an all-interfaces host becomes `localhost`, any other host is canonicalized), but it validates none of the three **port** variables: any non-empty port value is copied into the URLs and port-forwarding command as-is, with no range check, no warning, and no `SERVER_PORT` fallback, so a malformed value produces a broken hand-off rather than an error. + +## Public addresses (reverse proxy) + +The sandbox and app-origin servers advertise a URL built from their own bind (`http://localhost:6275/sandbox` under a wildcard bind). Behind a reverse proxy or an ingress, where the browser reaches them at a public hostname, set the address the browser should use instead. Neither changes what is bound: the process still listens on the port above, and routing the public address to it is the proxy's job. See [Behind a reverse proxy](../clients/web/README.md#host-binding--the-origin-allow-list). + +| Variable | Read by | Default | Effect | +| ----------------------------- | ------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_SANDBOX_FULL_ADDRESS` | web | `http://:/sandbox` | Public URL of the MCP Apps sandbox proxy, returned as `sandboxUrl` by `GET /api/config` and printed in the banner (e.g. `https://inspector-sandbox.example.com/sandbox`). A bare origin gets `/sandbox` appended. An empty value counts as unset. | +| `MCP_APP_ORIGIN_FULL_ADDRESS` | web | `http://:` | Public origin that `_meta.ui.domain` app documents are published under (e.g. `https://inspector-apps.example.com`). **Origin only** — a path is refused, since documents are served at `/app-document/`. An empty value counts as unset. | + +Both are **refused** — ignored with a warning, keeping the bind-derived address — when the value is not an absolute `http(s)` URL, carries credentials, a query string, a fragment or a wildcard, or is a bracketed IPv6 literal. ⚠️ **Each needs its own origin.** The MCP Apps spec requires the sandbox origin to differ from the Inspector's, so a sandbox address sharing an `ALLOWED_ORIGINS` origin is refused (`https://inspector.example.com/sandbox` behind the same hostname as the UI is the common case), as is an app origin equal to the Inspector's or the sandbox's. Neither is used unless its listener is on a fixed port — not when the port is `0`, and not when the pinned port was taken at startup (or collided with another Inspector port) and the server fell back to an OS-assigned one — since the proxy has no stable port to route it to. A plain-`http` address while `ALLOWED_ORIGINS` lists an `https` origin is used, but warned about: the browser blocks it as mixed content. + +## Behavior + +| Variable | Read by | Default | Effect | +| ------------------------ | ------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_AUTO_OPEN_ENABLED` | web, CLI | see effect | Whether a browser is opened for you. `false` never opens one; `true` always does. Unset (or any other value), the web client opens the UI at launch, and the CLI opens the OAuth authorization page only when stderr is a TTY. In the CLI, `true` also lets interactive OAuth **start** when neither stdin nor stderr is a TTY; otherwise that case fails with an auth-required error pointing at `--stored-auth-only`. **The TUI does not read it** and always opens the OAuth page. | +| `MCP_CATALOG_PATH` | web, CLI, TUI | `~/.mcp-inspector/mcp.json` | Default writable catalog, used when no `--catalog` is given. The CLI honors it only when no ad-hoc target (positional command, `--server-url`, or `--transport`) is given. See [MCP server configuration](./mcp-server-configuration.md). | +| `MCP_OAUTH_CALLBACK_URL` | CLI, TUI | `http://127.0.0.1:6276/oauth/callback` | Loopback redirect URL for the CLI/TUI OAuth flow. `--callback-url` takes precedence. | +| `NO_COLOR` | CLI | unset | Any non-empty value disables ANSI styling in the CLI's human-readable output. An empty `NO_COLOR=` counts as unset. | + +## Storage and state + +| Variable | Read by | Default | Effect | +| -------------------------------- | ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | +| `MCP_INSPECTOR_OAUTH_STATE_PATH` | CLI, TUI | `~/.mcp-inspector/storage/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a CLI or TUI run if this variable is also exported. **The web backend does not read it** — it always uses `/oauth.json`. | +| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | + +### Home directory + +Every default above that starts with `~` is built from the home directory the process sees, not from the OS account database: + +| Variable | Read by | Effect | +| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `HOME` | web, CLI, TUI | Base of `~/.mcp-inspector`: the default catalog, storage directory (`oauth.json`, `client.json`), secrets file, and TUI log directory. | +| `USERPROFILE` | web, CLI, TUI | Used in place of `HOME` when `HOME` is unset or empty — the normal case on Windows. ⚠️ If **neither** is set, as under some service managers, those defaults resolve against the **current working directory** instead. Set `HOME` or the specific path variables above. | + +## Secret store + +Where server secrets (headers, client secrets) are kept. The details — the keychain probe, the file format, encryption and locking — are in the [Docker guide](./docker.md); these variables apply to every install, not only containers. + +| Variable | Read by | Default | Effect | +| ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | +| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | +| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | + +When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. + +## Logging and debugging + +| Variable | Read by | Default | Effect | +| ----------------------- | ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_DEBUG` / `DEBUG` | launcher, TUI | off | Prints the full stack trace when the process exits on an error — the launcher for any `--web`/`--tui` failure, and the standalone TUI entry point for a startup failure. Any value other than empty, `0` or `false` (case-insensitive) turns it on. | +| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. An empty value counts as unset. | +| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | +| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log`: one of `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`. An empty value is not replaced by `info`. | + +## Outbound proxy + +| 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. | + +## Node.js variables + +These belong to Node, not the Inspector, but they are the answer to some common connection problems. Because the web client connects to MCP servers **from its Node backend**, not from the browser, they apply to all three clients. + +### Connecting to a server with a self-signed certificate + +A server at `https://localhost`, or anywhere else with a self-signed or privately issued certificate, fails to connect with a generic fetch error, because Node does not trust the certificate. Trusting the browser's exception does not help — the browser is not the one connecting ([#1936](https://github.com/modelcontextprotocol/inspector/issues/1936)). + +**Prefer trusting the certificate's CA.** `NODE_EXTRA_CA_CERTS` adds certificates to the ones Node trusts, and leaves verification on for every other connection: + +```sh +NODE_EXTRA_CA_CERTS=/path/to/your-ca.pem npx @modelcontextprotocol/inspector +``` + +For a self-signed certificate with no separate CA, point it at the certificate itself. Node reads the variable once at startup, so it must be set before launching. + +**Or turn verification off, for local development only:** + +```sh +NODE_TLS_REJECT_UNAUTHORIZED=0 npx @modelcontextprotocol/inspector +``` + +⚠️ This disables certificate verification for **every** TLS connection the Inspector process makes — every MCP server, every OAuth authorization server and token endpoint — not just the one you are testing, and Node prints a warning saying so. A connection that would have been refused for impersonation is then accepted. Never set it in a shell profile, a shared environment, or a deployment. + +| Variable | Default | Effect | +| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------- | +| `NODE_EXTRA_CA_CERTS` | unset | PEM file of extra CA certificates to trust, in addition to Node's bundled ones. Read once at startup. | +| `NODE_TLS_REJECT_UNAUTHORIZED` | `1` | `0` disables TLS certificate verification for the whole process. Development only. | diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 293d6cd6..f1abe745 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -11,7 +11,7 @@ Client-specific options (the web server port, the CLI method to invoke, TUI navi The two do not mix. `--catalog` and `--config` are mutually exclusive with each other, and neither combines with an ad-hoc target. All three clients apply the same **source-selection** rules — the CLI and TUI through the shared `serverSourceConflict` helper (`core/mcp/node/config.ts`), web through an equivalent inline matrix in `clients/web/server/run-web.ts`. The two implementations diverge on two narrow axes, in opposite directions: -- **Web is stricter on `--header`:** it also rejects `--header` alongside `--catalog`/`--config`, because the CLI and TUI merge `--header` into per-server settings and web does not. +- **Web is stricter on `--header` and `--protocol-era`:** it also rejects either alongside `--catalog`/`--config`, because the CLI and TUI merge them into per-server settings and web does not. - **Web is looser on `--transport stdio`:** the CLI and TUI treat _any_ `--transport` as an ad-hoc marker, so `--catalog c.json --transport stdio` is rejected as a catalog/ad-hoc conflict there; web excludes `stdio` from that test and accepts the same combination, silently ignoring the flag. ## From a file: `--catalog` vs. `--config` @@ -125,6 +125,7 @@ Without a `--` on the line the target is only the leading run of **non-dash** to | `--cwd ` | Working directory for a stdio server process | | | `-e ` | Environment variable for a stdio server; repeatable | | | `--header "Name: Value"` | HTTP header for an HTTP/SSE server; repeatable | On web, requires an ad-hoc HTTP/SSE server | +| `--protocol-era ` | `legacy`, `auto`, or `modern` — the era to negotiate | Sets [`protocolEra`](#inspector-specific-per-server-fields) without a file, on any transport. CLI/TUI also override a file's `protocolEra` with it; web, like `--header`, requires an ad-hoc target | | `[target...]` | Positional command or URL for one ad-hoc server | | **`MCP_CATALOG_PATH` and ad-hoc targets differ by client.** The **CLI** ignores the env var when an ad-hoc target is given (a positional command, `--server-url`, or `--transport`), so a shell that exports it can still run one-off ad-hoc invocations without tripping the catalog/ad-hoc conflict. **Web and TUI read it unconditionally** — with it exported, an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as `--catalog cannot be combined with an ad-hoc server URL/command`. Unset the variable for that invocation on those two surfaces. @@ -181,12 +182,15 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `modernLogLevel` | `"debug"` | Per-request log level stamped on modern connections, or `"off"`. Legacy connections ignore it | | `roots` | — | Roots advertised via the `roots` client capability; each is `{ uri, name? }` | | `metadata` | — | Default `_meta` payload merged into every outgoing request — a JSON object; values may be any JSON except for the reserved keys noted below. Sent by all three clients | -| `connectionTimeout` / `requestTimeout` | — | Timeouts in ms | +| `connectionTimeout` | `30000` | Connect-time timeout in ms (`DEFAULT_CONNECTION_TIMEOUT_MS`). `0` disables it; on timeout the error says where to raise it | +| `requestTimeout` | — | Per-request timeout in ms; absent or `0` leaves the SDK default | | `taskTtl` | `60000` | TTL in ms for tasks created via "Run as task" (`DEFAULT_TASK_TTL_MS`) | | `autoRefreshOnListChanged` | `false` | Refresh lists automatically on `*/list_changed` instead of only flagging the indicator | | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | +| `skillCatalogMaxSkills` | `256` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | +| `skillCatalogMaxBytes` | `67108864` | The maximum number of bytes read across all skills in one verification run (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer | | `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 08d25b36..15afab2e 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -12,16 +12,20 @@ Each client self-validates from its own folder; the root scripts chain them. The | Tier | How it runs | What it covers | | --- | --- | --- | | **GitHub CI** (`.github/workflows/main.yml`) | Automatically, on every push | `npm install`, then `validate`, `verify:skills:cli`, `verify:build-gate`, `verify:bundle-externals`, `smoke` (which includes `smoke:web:chromium`), `test:storybook` — plus `coverage` in a parallel job ([#2159](https://github.com/modelcontextprotocol/inspector/issues/2159)) | -| **The local gate** (`npm run local:gate`) | By hand, before you push | Every check above (the install is yours to run), **plus** the Firefox engine pass (`smoke:web:firefox`), and `smoke:tui` for real rather than self-skipped | +| **The local gate** (`npm run local:gate`) | By hand, before you push | Every check above (the install is yours to run; `local:validate` stands in for `validate`, see below), **plus** the Firefox engine pass (`smoke:web:firefox`), and `smoke:tui` for real rather than self-skipped | -The local gate is a **strict superset**, not a mirror. Two of its steps have no GitHub CI counterpart, each for its own reason: +The local gate runs **every check** CI runs, and is not a mirror. Two of its steps have no GitHub CI counterpart, each for its own reason: | Local-only step | Why it is local-only | | --- | --- | | `smoke:web:firefox` | Trialled as a CI job and removed ([#2086](https://github.com/modelcontextprotocol/inspector/issues/2086)): across a dozen runs it never once disagreed with Chromium, and `playwright install --with-deps` carries a real flake surface. Kept in front of a human about to push instead. `smoke:web:webkit` is worse still — it fails two of the three smokes for reasons nobody has identified — so it is in neither tier. See [Supported browsers](#supported-browsers). | | `smoke:tui` | The Ink TUI needs a real TTY. It _is_ invoked in CI via `npm run smoke` and self-skips there on `process.env.CI`, so it needs no guarding — it handles itself. | -So the direction that matters holds: **passing `npm run local:gate` means CI's gates will pass.** The reverse does not. +And one CI *invocation* the gate deliberately does not repeat: CI runs each client's unit suite **twice** — bare inside `validate` in the `build` job, instrumented inside `coverage` in the parallel job — at no wall-clock cost, because the two jobs run on separate runners. Serially in one process the **bare** pass was ~81s of a ~358s quiet gate — the baseline measured on [#2338](https://github.com/modelcontextprotocol/inspector/issues/2338) — running exactly the test files the coverage pass runs again a few minutes later ([#2341](https://github.com/modelcontextprotocol/inspector/issues/2341)). So the gate's first stage is **`local:validate`**: the same guards and `validate:core`, then each client's `check` — `format:check` + `lint` + `typecheck`, plus `build` for web, tui and launcher, which name one explicitly; cli's `validate` built only through `test`'s `pretest` hook, so dropping the `test` leg drops that build too, and `coverage:cli` builds the binary once, later — instead of its `validate` (`check` + `test`). The suites still run once, under `coverage`. That subsumes the bare pass because `@vitest/coverage-v8` reads V8's own precise-coverage profiler and rewrites nothing — the code under test is byte-identical, and the instrumented run is only slower, which is the stricter direction for the failures this repo sees. `npm run validate` and both CI jobs are unchanged. With the duplicate gone a quiet gate is **~260s** (268s in #2341's own before/after, 257s where [#2339](https://github.com/modelcontextprotocol/inspector/issues/2339) measured it) — a reader who remembers six minutes is remembering the gate before #2341. + +So the direction that matters holds: **passing `npm run local:gate` means every check CI applies has already passed on your machine** — the strongest predictor of a green CI this repo has, though not a proof: CI runs on a different OS, and the bare pass above is the one invocation it has that the gate does not, so a test that passes *only* when instrumentation slows it down would surface there first. That test is a race ([#1596](https://github.com/modelcontextprotocol/inspector/issues/1596)) to fix, not a reason to put the second pass back. The reverse direction does not hold at all: CI green says nothing about the Firefox pass or the real `smoke:tui`. + +**Every CI job carries `timeout-minutes`** ([#2333](https://github.com/modelcontextprotocol/inspector/issues/2333), PR [#2349](https://github.com/modelcontextprotocol/inspector/pull/2349)): `build` 25, `coverage` 20, `publish` 10, `publish-github-container-registry` 35. Each is roughly **twice the slowest observed run, rounded up to the next five minutes** — `build` was read from 7.6–11.8 min and `coverage` from 5.9–8.5 min across the 40 push runs before it landed, the two publish jobs from 2.3–2.8 and 14.5–15.4 min across the last three releases — and the range each was read from is stated beside the job in the workflow, so the next raise carries a new range rather than a guess. It is a **hung-job guard, not a flake remedy**: GitHub runners are not the contended machine the local gate runs on, nothing measured implicates them, and no value there is sized for load. A release-run `build` before the coverage split ([#2159](https://github.com/modelcontextprotocol/inspector/issues/2159)) reached 15.6 min with the coverage gate still inside it; that shape no longer exists, so it is not in the range. **The gate is deliberately not named `ci`** ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)). That name collided with `npm ci` — a built-in that clean-installs from the lockfile, does **not** run this script, and is one keystroke away — and it invited the cross-engine passes into GitHub CI by looking like the thing a workflow ought to call. There is **no back-compat alias**: `npm run ci` now fails with npm's missing-script error, which prints the available scripts. @@ -29,18 +33,21 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor | Script | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (two tiers: no dependency reaching one `tsc` program from two installs skews across them, and — since #2226 — no **declared** package holds two different top-level versions across installs at all) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | -| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web — the TUI one runs under a **pseudoterminal** and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, [#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). A fourth, **`smoke:web:tabs`**, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its `structuredContent` section, Resources reads a resource with templates listed, Prompts fetches one ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)) — asserting the `data-*` contract in [the web README](../clients/web/README.md#core-tab-automation-contract) rather than visible copy. It is **Chromium-only**: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. | +| `npm run validate` | Runs the five durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (two tiers: no dependency reaching one `tsc` program from two installs skews across them, and — since #2226 — no **declared** package holds two different top-level versions across installs at all), `verify:test-timeouts` (every Vitest project resolves to a budget somebody chose, both web projects configure Testing Library's `asyncUtilTimeout`, and every project loads the runtime no-retry setup) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). **`maxWorkers` is deliberately unset**, here and in every Vitest project ([#2336](https://github.com/modelcontextprotocol/inspector/issues/2336), PR [#2345](https://github.com/modelcontextprotocol/inspector/pull/2345)): the forks-pool projects inherit Vitest's `max(availableParallelism() - 1, 1)` — 7 on the eight-logical-core machine this team works on — and keeping it is a choice with a measurement, recorded beside `TIMEOUTS` in `vitest.shared.mts`: at the leased baseline, arms interleaved over three rounds, `maxWorkers: 4` cost +14% wall on web `unit` and +39% on `coverage:web` for zero fewer failures (0 across all 18 runs). See [Multi-agent testing](#multi-agent-testing-the-gate-lease) for why a cap was the wrong lever for the contention it was proposed against. | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web — the TUI one runs under a **pseudoterminal** and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, [#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). A fourth, **`smoke:web:tabs`**, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its `structuredContent` section, Resources reads a resource with templates listed, Prompts fetches one ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)) — asserting the `data-*` contract in [the web README](../clients/web/README.md#core-tab-automation-contract) rather than visible copy. It is **Chromium-only**: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. The Playwright budgets the browser smokes wait under are **named once**, in `scripts/lib/browser-timeouts.mjs` (`BROWSER_TIMEOUTS`: `ui` 30s, `roundTrip` 45s, `nested` 15s, `bestEffort` 5s — [#2323](https://github.com/modelcontextprotocol/inspector/issues/2323)); since [#2333](https://github.com/modelcontextprotocol/inspector/issues/2333) (PR [#2349](https://github.com/modelcontextprotocol/inspector/pull/2349)) the shared flow helpers `deep-link-connect.mjs` and `mcp-app-flow.mjs` default to those constants instead of carrying literals of their own, and the two helper budgets that stay put are **stated at the site with the measurement behind them**: `announced-child.mjs`'s `DEFAULTS` (a 30s readiness ceiling on a 250ms poll — boot-to-announce measured 135ms median through it at load average 15, so a passing start pays one poll interval, not the budget) and `pty.mjs`'s `SCRIPT_PROBE_TIMEOUT_MS` (5s for the `script --version` probe, measured at 1.3ms median). That probe's expiry is now a **failure, not a skip**: a timed-out `spawnSync` reports in the same `error` slot as `ENOENT`, and reading both as "no `script(1)` on PATH" had turned `smoke:tui` into an exit-0 skip on exactly the loaded machine the gate is meant to be believable on. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run local:gate` and of GitHub CI. | | `npm run verify:bundle-externals` | Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the **built** `build/` output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own `external` array **and the root manifest's `dependencies`** — the latter because #2067 was a *missing* `external` entry, which a self-referential check would have passed. Detection is via esbuild's `// ` module banners, so it covers both shapes — a separate `-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces) and a statically-imported package folded straight into `index.js`, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling `minify` cannot silently retire the check. `undici` was declared only in the root and `clients/cli` manifests, and tsup auto-externalizes only what the _nearest_ manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. Part of `npm run local:gate` and of GitHub CI. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:skills` | Guards `.claude/skills` ([#2163](https://github.com/modelcontextprotocol/inspector/issues/2163)). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior **silently** — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an *empty* description: `/skill-name` still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each `SKILL.md` the same way, requires an explicit invocation mode on every skill and committed eval cases (positives **and** negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs `claude plugin validate` — the authoritative schema — when the installed CLI is **exactly** the pinned version, and otherwise says so and moves on. It runs inside `validate`, which `local:gate` executes *before* the pinned step, so accepting any other local CLI would let it reject skills that CI accepts and exit the gate before the reproducible check ever ran. Runs in `validate`; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see `verify:skills:cli` below. | +| `npm run verify:skills` | Guards `.claude/skills` ([#2163](https://github.com/modelcontextprotocol/inspector/issues/2163)). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior **silently** — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an *empty* description: `/skill-name` still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each `SKILL.md` the same way, requires an explicit invocation mode on every skill and committed eval cases (positives **and** negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs `claude plugin validate` — the authoritative schema — when the installed CLI is **exactly** the pinned version, and otherwise says so and moves on. It runs inside `validate:guards`, which both `validate` (CI) and `local:validate` (the gate's first stage) execute *before* the pinned step, so accepting any other local CLI would let it reject skills that CI accepts and exit the gate before the reproducible check ever ran. Runs in `validate`; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see `verify:skills:cli` below. | | `npm run verify:skills:cli` | Runs `claude plugin validate` — the **authoritative** skill schema — as a guaranteed step, in `local:gate` and in CI. `verify:skills` reimplements the parse and *skips* when the CLI is absent, which is right for `validate` (fast, offline, no Claude Code required) but adds up to "never runs". This one resolves the CLI instead: an installed one **only when it matches the pin exactly**, otherwise the pinned package via `npx -y @anthropic-ai/claude-code@…`. Exact, not a floor: accepting a newer local CLI would validate against a different schema than CI's, so the same `local:gate` could disagree across machines — which is the failure a pin exists to prevent. Needs no authentication. Not in `validate` — it can reach the network. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. `gate-lease.test.mjs` ([#2339](https://github.com/modelcontextprotocol/inspector/issues/2339), PR [#2344](https://github.com/modelcontextprotocol/inspector/pull/2344)) covers the gate's lease wrapper the way `announced-child` and `render-smoke` are covered — against a **real** `proper-lockfile` lock and real child processes: two runs serialize; a stale lock is taken over while one that cannot be removed is waited on; the give-up names the holder; the bypass variable and an unusable lease directory each degrade to an unleased run; and a real `SIGTERM` to the wrapper stops the grandchild, releases the lease and exits 143. Every case uses a throwaway lease directory, so the suite can neither queue behind nor block a developer's actual gate, and an `after` hook removes them ([#2346](https://github.com/modelcontextprotocol/inspector/issues/2346), PR [#2351](https://github.com/modelcontextprotocol/inspector/pull/2351): 14 directories left behind per run → 0). `workflow-gate.test.mjs` also pins `local:gate` to being *exactly* the wrapper over `local:gate:stages` — a stage placed beside the wrapper would run outside the lease — and every assertion about the gate's shape reads the stages script directly. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. A **second tier** (#2226) runs alongside it, asking the weaker but broader question the `AGENTS.md` rule actually states: does a package this repo *declares* anywhere resolve to two versions across our installs at all? Its candidate set is every name in any install's `dependencies`/`devDependencies`/`optionalDependencies` — unioned across the root and all four clients, so a copy declared by only one of them still counts — that **more than one install holds a top-level copy of** (17 packages today). Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours. Neither tier subsumes the other — the program tier sees a copy no manifest names (`@modelcontextprotocol/sdk`, arriving through another package's `.d.ts`), while the declared tier sees a **transitive** copy no program loads (cli's `@types/node`, hoisted via `@types/express` — the case that motivated it), two **clients** disagreeing with no root copy involved (`@types/react`, web against tui), and the peer shadows `eslint`/`typescript`/`vitest` that never enter a program. Same deny-by-default and same within-a-major rule, against its own `TOLERATED_DECLARED_SKEW` — also empty. Two limits: it reads **lockfiles**, so an uncommitted hand-installed copy is invisible, and it compares only declared names, so a purely transitive package no manifest names stays the first tier's business. Runs in `validate`. -| `npm run local:gate` | **Mandatory pre-push command.** `validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. A strict superset of GitHub CI — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. | +| `npm run verify:test-timeouts` | Guards the wall-clock budgets the test gates run under (#2323). The class it encodes against is a budget **nobody chose**: three of the six Vitest projects ran on Vitest's own `testTimeout: 5000` and five on its `hookTimeout`/`teardownTimeout: 10000`, sized for an idle machine rather than the one this team works on — three or four concurrent agent sessions in separate worktrees, each free to run a full `local:gate`, on eight logical cores. A correct, deterministic test cut off by such a budget fails a gate its diff did not break, which is the same "channel nobody trusts" failure [Lint has no warning tier](../AGENTS.md#lint-has-no-warning-tier) describes from the other direction; #2292, #1942 and #1742 were each an instance, found one site at a time. Deliberately **not** cited: #2278 (a missing condition wait around a geometry read) and #2250 (a real race in a test's own timing) were fixed by making the test wait for the right thing, and presenting a race fix as evidence for a larger ceiling would argue against #1596. It asks **Vitest itself** to resolve each of the four configs and reads the number a test actually gets, rather than checking that a key is absent from a config block — which would pass just as happily on a config that had stopped being loaded. A **seventh project** with no row, or a config file it does not discover (it knows all twelve filenames Vitest accepts, not just the two this repo uses), is an error rather than a silent skip. It also checks that every project loads `vitest.setup.shared.mts`. ⚠️ **Everything it reads comes from a resolved project, never from source text** — the two rules no config can report are asserted at runtime instead: `retry` by `vitest.setup.shared.mts` (which reads the value Vitest resolved, so a per-test option, a `describe` option, a project setting and a `--retry` flag are one check) and Testing Library's `asyncUtilTimeout` by `clients/web/src/test/asyncUtilTimeout.test.ts` (which reads what the project's own `waitFor`s use). Both began as source scanning in #2334 and the review found a new valid spelling missed in five consecutive rounds, so the boundary is deliberate: if a rule cannot be answered by asking the tool, assert it at runtime rather than reading the source for it. Observed **red** against the pre-#2323 config before it was trusted. Runs in `validate`; its decision logic has a sibling `verify-test-timeouts.test.mjs` under `test:scripts`. +| `npm run local:gate` | **Mandatory pre-push command.** `local:validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. Every GitHub CI check plus two local-only ones — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. **Runs under a machine-wide lease** (`scripts/gate-lease.mjs`, [#2339](https://github.com/modelcontextprotocol/inspector/issues/2339)): a second `local:gate` started in another worktree waits for the first to finish instead of running alongside it. Measured on `aa56551b`, a quiet gate took 257s; two started together had one **fail** at 279s on a smoke port collision (`smoke:web:chromium` binds 6298, and so did the other gate's) and the survivor take 338s — so overlapping gates are red by construction, not merely slow, and back to back both finish green in ~2x257s. The wait prints the holder's pid and worktree; a holder that dies without releasing is taken over after 30s (`proper-lockfile` stale detection) — unless its lock directory cannot be removed, in which case the wait runs to its 45-minute cap and names the path; `INSPECTOR_SKIP_GATE_LEASE=1` bypasses it. The stages themselves are `local:gate:stages`, which the wrapper runs verbatim. Why it is a queue rather than a load wait or a worker cap, and what it does and does not change about capacity, is [Multi-agent testing](#multi-agent-testing-the-gate-lease). | +| `npm run local:validate` | The gate's first stage: `validate` with each client's `test` leg removed (#2341) — the same `validate:guards` and `validate:core`, then every client's `check` — `format:check` + `lint` + `typecheck`, plus `build` for web, tui and launcher, which name one explicitly (cli's only validate-time build was `test`'s `pretest` hook, so it goes with the `test` leg and `coverage:cli` builds the binary once, later) — each client's `validate` being `check && test`. The suites run once, under `coverage`, instead of bare here and instrumented there. In the `local:` namespace so the workflow guard keeps it out of CI, where the bare pass is free. | +| `npm run local:storybook` | The gate's last stage: from `clients/web`, `npx playwright install chromium` and then `test:storybook` — the Storybook play functions, run headless by Vitest's browser project (CI runs `test:storybook` directly after its own Playwright install step, which is why this wrapper is in the `local:` namespace). Since [#2340](https://github.com/modelcontextprotocol/inspector/issues/2340) (PR [#2342](https://github.com/modelcontextprotocol/inspector/pull/2342)) the project's `optimizeDeps` carries `force: true` (`getStorybookOptimizeDeps` in `clients/web/server/vite-base-config.ts`), so Vite discards its dep pre-bundle cache and re-scans every story entry before the browser opens — the cold path CI takes on every PR. Without it, Vite keys that cache on the lockfile and the config, never on what the story graph imports, so after a story gains an import of an already-installed package the "valid" cache re-runs the optimizer mid-run and sends a `full-reload` the Vitest tester iframes do not act on; every story an already-loaded iframe renders from then on fails with `Failed to fetch dynamically imported module`, and the next run is green because the cache has caught up. Measured: a stale cache went 3/3 red → 3/3 green with `force` → 3/3 red reverted, load refuted as a cause (0 of 6 under load 8–12), cost ~0.8s per run against a ~25s stage. | | `npm run pack:verify` | Publish smoke — see [Publishing](./publishing.md). | Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. @@ -66,7 +73,7 @@ Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run loc **Firefox passes all three smokes. WebKit fails the two App smokes**, for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort — treat a WebKit failure as unexplained rather than as a defect until someone has looked. -**Why Firefox is in the pre-push gate rather than in CI.** A GitHub Actions job was trialled and was cheap — about two minutes, in parallel with the 15-minute `build` job, so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it spent runner minutes on every push from every branch to re-confirm a result already in hand, and carried a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks). +**Why Firefox is in the pre-push gate rather than in CI.** A GitHub Actions job was trialled and was cheap — about two minutes, in parallel with the then-15-minute `build` job (it still carried the coverage gate; see the CI budgets above), so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it spent runner minutes on every push from every branch to re-confirm a result already in hand, and carried a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks). 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. @@ -76,3 +83,33 @@ Putting it in `npm run local:gate` instead keeps the check where it is worth mos `pack:verify` stays Chromium-only on purpose — it is a _packaging_ check, and the engine question belongs where the sandbox is under test. +## Multi-agent testing: the gate lease + +The machine this repo is worked on runs three or four agent sessions at once, each in its own worktree and each free to run `npm run local:gate`. [#2338](https://github.com/modelcontextprotocol/inspector/issues/2338) is the effort that made the gate believable there; [#2339](https://github.com/modelcontextprotocol/inspector/issues/2339) is its coordination aspect, shipped as PR [#2344](https://github.com/modelcontextprotocol/inspector/pull/2344). This section is what that coordination is, why it is a queue, what it changed about capacity, and what it leaves alone. The diagnosis side — what to do when a gate is waiting, or when everything times out at once — is the `pre-push-gate` skill. + +### 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. + +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. + +### Why a queue, and not a load-based wait or a worker cap + +Each alternative was ruled out by a measurement rather than an argument: + +- **Overlapping gates are red by construction, so no budget or worker cap could make two of them green.** The web smokes bind fixed ports — 6295–6299 by default (`smoke:web:tabs` 6295, `smoke:web:elicit` 6296, `smoke:web:app` 6297, `smoke:web:browser` 6298, `smoke:web` 6299), plus the sandbox and app-origin ports. Measured for #2339 on `aa56551b`: a quiet gate took 257s and passed; two started together in separate worktrees had one **fail** at 279s — `smoke:web:chromium` found port 6298 held by the other gate's copy of the same smoke — while the survivor took 338s, ~1.3x, with zero test timeouts. That is a red run on a diff that cannot have caused it, the exact failure #2338 exists to stop, and only not overlapping fixes it. (The corollary corrected the effort's own framing: at two sessions the load inflation is ~1.3x; the 2.5x recorded on [#2323](https://github.com/modelcontextprotocol/inspector/issues/2323), with ~326 tests timing out at 5s, was the four-worktree number.) +- **A load-threshold wait deadlocks.** Two sessions each polling "is the machine quiet yet?" never clear, because each is the reason the other is waiting — the trap #2339 recorded before any code existed. A lease *grants* instead: exactly one waiter wins the `mkdir`, runs, and releases; the rest keep asking until they acquire it or exhaust their own 45-minute budget. There is nothing to wait *for* except the holder's release — by exit, by signal, or by the stale takeover above — and the wait for it is bounded, which is what a load threshold cannot promise. The one case where no release ever comes is the unremovable stale lock above, and there the waiter still exits at its budget naming the path rather than waiting forever. A `pgrep -f "npm run local:gate"` loop is the same trap from another side — it matches itself and never exits. +- **A worker cap is a permanent solo-run cost bought against a failure that does not occur at the leased baseline.** Measured for #2336 (PR [#2345](https://github.com/modelcontextprotocol/inspector/pull/2345)), arms interleaved over three rounds, nothing else running: `maxWorkers: 4` cost +14% wall on web `unit` and +39% on `coverage:web` — ~35s on every `coverage:web`, a tenth of the whole gate — for zero fewer failures (0 across all 18 runs). With gates serialized, the oversubscription a cap would mitigate comes only from non-gate work in a sibling session, and that arm was measured separately against a concurrent bare unit run: total throughput was the same with both sides capped as with both at the default, because eight cores are saturated either way, and the one thing a cap moved was failures — 2 of 10 uncapped 7-vs-7 runs lost `AppRenderer.test.tsx`'s theme-flip case to its 5s inner `waitFor` at 14 workers, against 0 of 12 with a cap on either side. That is a single test's inner budget starving under two sessions' worth of workers, fixable at that site, not a case for taxing every solo run. So `maxWorkers` stays unset, stated as a choice beside `TIMEOUTS` in `vitest.shared.mts`. + +Two further calls, recorded on PR #2344: the lease covers the **whole gate**, not only its parallel stages, because the serial stages are not free (type-aware lint, a full Vite build) and per-stage leases would let two gates interleave into the same contention; and it is **on by default**, because the sessions that need it are the ones that did not think to opt in. + +### What it means for capacity + +**One gate runs at a time — before and after.** What changed is what happens when a second one is started. Before, a second start was a wasted run: one of the two went red on the port collision regardless of budgets, so the honest capacity was one green gate at a time. After, as many as fit inside the wait budget — about ten, see below — can be **started** from separate worktrees, and each runs back to back with no other gate contending for the machine or its ports. What the lease removes is gate-on-gate contention, not any other reason a check can fail; measured, three started within seconds of each other all completed green with zero timeouts. Each gate is also shorter (a quiet run went ~358s → ~260s with #2341), so a queue drains sooner: three queued gates finish in roughly 13 minutes in total, against one green, one red and a re-run before. The ceiling on queue depth is the 45-minute wait budget — at ~4.5 minutes per gate, about ten can be queued before the last gives up and names the holder, so a deeper queue is one where the last starts give up rather than wait. Whether truly parallel gates could ever be green was not measured: the port collision makes that a different project (per-gate port allocation), not a tuning problem, and nothing in #2338's goal needs it. The measurements are collected in [#2338's epilogue](https://github.com/modelcontextprotocol/inspector/issues/2338). + +### What it does not do + +- **No per-stage locks.** The whole gate is one lease, and nothing finer. +- **No degraded mode.** A queued gate runs at full concurrency against the same quiet baseline every other #2338 number was tuned to; it never runs smaller to fit beside another. +- **No coordination with non-gate work.** Only `local:gate` takes the lease, so a bare `vitest run`, an `npm run coverage` or a `test:storybook` in another session still contends with a running gate — and so does Spotlight indexing a fresh `node_modules`. When everything times out at once, look for one of those rather than for a second gate. +- **Nothing in CI.** The wrapper is reached only through `local:gate`, which `scripts/lib/workflow-gate.mjs` already keeps out of every workflow; GitHub runners are separate machines and need no lease. diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 2f6f2851..8582c732 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -201,6 +201,17 @@ ever taken.** `test-servers` scores 5/5 on its own cases and every one of them asks for it by name; a skill only ever reached _through_ another would score a clean 100% while the hand-off silently never fired (#2204). +⚠️ **A green hand-off number is not evidence the hand-off is USEFUL.** The case +can observe one thing — that skill B was loaded. Whether B then answers the +question the prompt actually asked is outside what any `chain` case measures, in +either direction: B can be loaded and say nothing relevant, or say something +actively wrong for the caller that arrived through A. `testing` -> +`test-servers` scored 100% while `test-servers` documented only the two-process +manual path, so every model that followed the pointer from an *integration-test* +prompt was handed the wrong half of the procedure (#2264). So when a chain case +goes green, **read B's body as the caller who arrives through A** and check that +what it says fits that caller. The number cannot do that for you. + A chained case names the ordered skills one run should load: ```json @@ -285,8 +296,8 @@ license (Copilot). A strict bound of `1.0` is therefore unreachable and the harness rejects it up front rather than failing every case. 0.5 is a floor on **useful reliability for a second-hop load**, not a claim that -0.8 is out of reach — a well-shaped pointer clears it outright, at 100% in the -worked example below. Note that a *chain* bar of 0.8 would be a **strict** one +0.8 is out of reach — the one pointer measured after reshaping cleared it +outright, at 100% in the worked example below. Note that a *chain* bar of 0.8 would be a **strict** one (`> 0.8`, the comparison this threshold uses; the first-move 0.8 is the inclusive `>=`), so at `RUNS=5` only a clean 5/5 would pass it — 4/5 would not. What 0.5 buys is that the column keeps carrying signal across the *range* of pointer strengths a repo actually has: a hand-off is a noisier @@ -319,11 +330,18 @@ measured 100% / 80% on the full suite and 100% / 100% on a focused noise, well short of the 67 above.) The transferable part is that **a pointer is followed when it reads as an action -with a trigger, and skimmed when it reads as a fact.** #2202 found the same -lever on a *description*'s shape; this is it applied to a body. The corollary is -where to put one: the top of a body is read before the model knows it needs the -second skill, so a pointer that lives only there is a pointer it has already -scrolled past by the time it matters. +with a trigger, placed where the decision is actually made.** #2202 found the +same lever on a *description*'s shape; this is it applied to a body. + +⚠️ **Read that as ONE lever, not two.** The #2247 edit changed the wording *and* +the placement in the same revision, and only the combination was measured, so +the run establishes the pair — it does not license attributing the rise to +imperative phrasing alone, nor to repetition alone. The split into two levers is +the plausible reading of the mechanism, not a measured result: the top of a body +is read before the model knows it needs the second skill, so a pointer that +lives only there has plausibly been scrolled past by the time it matters. Taking +them apart would need two more runs — reword without moving, and move without +rewording — and nobody has paid for those. Until someone does, ship both. ⚠️ **Do not read a rise between two `RUNS=3` runs as an improvement.** One sample is 33 points there, and the two weak-pointer runs above (33% / 33% and @@ -452,7 +470,9 @@ break. hand-off has a `chain` case — a pointer between skills is otherwise measured by nothing at all, and a skill reached only that way scores a clean 100% on direct cases while the hand-off never fires. It does not count toward the - floor in 4. + floor in 4. **Then read the target's body as the caller arriving through that + pointer** — the case proves the load, never that what loads is the half that + caller needs (#2264). 6. `npm run verify:skills` passes and the listing is under budget. 7. `RUNS=5 npm run skills:eval` — the **whole** suite — is ≥80% on every first-move case, including the skills you did not touch, and the hand-off diff --git a/docs/test-servers.md b/docs/test-servers.md index 4b568162..17fde6ce 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -55,12 +55,15 @@ as a missing capability rather than an error. | `oauth-revocation-http.json` / `oauth-no-revocation-http.json` **(legacy era)** | RFC 7009 token revocation on clear, with and without a `revocation_endpoint` | [#2144](https://github.com/modelcontextprotocol/inspector/issues/2144) | | `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) | | `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) | +| `oauth-cimd-http.json` **(legacy era)** | URL-based client IDs (CIMD / SEP-991), DCR deliberately off | [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) | +| `oauth-stalled-{token,discovery}-http.json` **(legacy era)** | An OAuth endpoint that accepts the request and never answers, so the OAuth-path request timeouts fire against a real idle socket | [#2382](https://github.com/modelcontextprotocol/inspector/issues/2382) | | `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | | `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) | | `tasks-{legacy,modern}-http.json` **(era per file)** | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | | `cancellation-modern-http.json` **(modern era)** | Cancelling a call by closing its response stream | [#2140](https://github.com/modelcontextprotocol/inspector/issues/2140) | | `skills-http.json` **(either era)** | Skills tab: `skills/list`, `resources/directory/read`, digest verification, the frontmatter cross-check, and the non-conforming cases | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) | +| `skills-strict-{legacy,modern}-http.json` **(era per file)** | The same skills, served only to a client that **declared** `io.modelcontextprotocol/skills` itself (SEP-2133) | [#2373](https://github.com/modelcontextprotocol/inspector/issues/2373) | ## Skills (SEP-2640) @@ -76,6 +79,37 @@ handler cannot reach it. To exercise the *undeclared* case, connect to any config **without** `"skills"`, where the Inspector must refuse to send the call locally rather than letting the server answer it. +⚠️ **`skills-http.json` serves any client, including one that never declared the +extension.** SEP-2133 negotiates an extension from both sides, and a strict +server refuses `skills/*` to a client that did not declare +`io.modelcontextprotocol/skills` itself. The Inspector shipped without that +declaration and passed against this fixture regardless +([#2373](https://github.com/modelcontextprotocol/inspector/issues/2373)). +`skills-strict-legacy-http.json` (port 3232) and +`skills-strict-modern-http.json` (port 3233) add +`"skillsRequireClientExtension": true`, which refuses `skills/list`, +`skills/get` and `resources/directory/read` unless the client declared it +(skill files still come through ordinary `resources/read`, which needs no +extension). The refusal differs by era: a **modern** request gets SEP-2575's +`-32021` MissingRequiredClientCapability (HTTP 400) with the Skills extension +under `data.requiredCapabilities`; a **legacy** one, whose era has no such code, +gets `-32601`. ⚠️ **Connect each with its own era.** A modern request +carries the declaration in its `_meta` envelope; a legacy one relies on what +`initialize` declared, which only a stateful legacy server keeps. A legacy +client reaching the modern file is served statelessly, holds no declaration, +and is refused however it is configured — so there is one file per era rather +than one for both: + +1. Connect — the Inspector declares the Skills extension by default, so the + Skills tab lists the same eight skills. The client's declaration is visible + in the `initialize` request (Legacy) or in each request's + `io.modelcontextprotocol/clientCapabilities` `_meta` entry (Modern). +2. Open **Server Settings → Advertised Extensions**, uncheck + **Skills (io.modelcontextprotocol/skills)**, and reconnect. +3. `skills/list` now fails — with `-32021` MissingRequiredClientCapability on + the modern file and `-32601` on the legacy one, the refusal a strict server + sends in each era. + **Both `skills/*` results carry the full modern base envelope** (`resultType` / `ttlMs` / `cacheScope`). They are consumer-owned methods, so the SDK stamps nothing for them; without it a 2026-era connection would receive a result @@ -90,8 +124,10 @@ words and says nothing of the kind for this method, whose one worked example carries `resultType` and nothing else. A fixture sending more than the SEP shows would make a client that wrongly *required* them look correct, which is the opposite of what a conformance fixture is for — so `readDirectoryPage` stops -where the spec does, and `ModernDirectoryReadResultSchema` requires exactly as -much. +where the spec does. On the client, the SDK's modern codec enforces +`resultType` and lifts it off before the Inspector's own schema runs, so +`DirectoryReadResultSchema` serves both eras and requires no caching attributes +([#2373](https://github.com/modelcontextprotocol/inspector/issues/2373)). It works on **either era**: `skills/list`, `skills/get` and `resources/directory/read` are consumer-owned extension methods that neither @@ -242,7 +278,7 @@ Only the **web** client advertises the nested client-side setting, and only beca Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute `ui://` URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise `elicitation`, a request that times out, and any result that is not a valid `ElicitResult` for the requested schema. An explicit `decline` or `cancel` is **not** a fallback — it is a completed elicitation and goes back to the server as-is. -> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. +> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: #733 is not yet in a published release — the installed `@modelcontextprotocol/ext-apps` (2.0.0) does not include it. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. ## MRTR @@ -495,7 +531,7 @@ Requiredness is a property of the **expression**, not the variable: RFC 6570 dro 1. Connect — the Inspector advertises the Tasks extension by default, so the Tools list shows both `echo` and `get_weather`. 2. Open **Server Settings → Advertised Extensions**, uncheck **Tasks (io.modelcontextprotocol/tasks)**, and reconnect. -3. The client now advertises no extensions, the server never enables `get_weather`, and the Tools list shows only `echo`. +3. The client no longer advertises the Tasks extension (it still advertises the others, such as MCP Apps UI and Skills), the server never enables `get_weather`, and the Tools list shows only `echo`. This is the debugging knob for a server legitimately changing tool registration based on what the client advertises. Legacy stateful leg only — the modern per-request leg has no persistent `oninitialized`. @@ -534,6 +570,100 @@ On the broken build you got a **"Re-authentication required"** banner with a **R Note that the fix here is presentational only. Making a `*.localhost` token endpoint actually **work** has to land in the SDK — the assertion runs inside `executeTokenRequest`, takes no options, and there is no hook the Inspector could reach. +## URL-based client IDs (CIMD / SEP-991) + +`oauth-cimd-http.json` is a combined AS + resource server that advertises +`client_id_metadata_document_supported: true` and — the part that makes it usable — **hosts the client +metadata document itself**, at `/client-metadata.json`. Plain streamable-HTTP; connect with the +**default (legacy)** protocol era. + +Hosting the document is the whole reason this fixture exists. In CIMD the `client_id` *is* a URL that +the authorization server dereferences to learn the client's metadata, so a server that merely +advertises support is only half a fixture: exercising it still meant standing up a second host by +hand. That is why [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) shipped +verified by its tests alone, and the v2.6.0 release ledger recorded it as the one row that had an +observable UI surface but no way to reach it. + +**`supportDCR` is `false` on purpose.** With both registration paths available a successful connection +proves nothing about which one ran — precisely the confusion #2242 was about, where Connection Info +reported `Dynamic (DCR)` for a connection that never issued a `POST /oauth/register`. With DCR off, +CIMD is the only way the flow can complete, so reaching a connected state *is* the assertion. + +⚠️ **CIMD is configured install-wide, not per server.** It lives in `client.json` +(`~/.mcp-inspector/storage/client.json`) as `cimd: { enabled: true, clientMetadataUrl }`, reachable +from **Client settings**, not from a server's own OAuth settings. A `clientMetadataUrl` written into a +catalog entry's `oauth` block is silently ignored — and with `supportDCR: true` the connection then +succeeds *via DCR*, which looks like CIMD working until you read the client id. + +**Use this server's own document as the `clientMetadataUrl`** — `/client-metadata.json` on the origin +the server announced on startup (`http://127.0.0.1:8092/client-metadata.json` when it got its +configured port; read the announced URL, since this fixture walks upward on `EADDRINUSE` like every +other one here). The Inspector requires HTTPS *except* on the three +loopback literals `localhost`, `127.0.0.1` and `[::1]` +(`getCimdClientMetadataUrlError` in `core/client/config-parse.ts`, applied to `client.json` on disk as +well as to the settings form), which is the same exemption the SDK applies to token endpoints and what +the runtime already tolerated for an already-stored `client_id`. Give the URL the host the server is +actually listening on; a host outside those three — `localhost.` and `tenant.app.localhost` included, +even though both resolve to loopback — is still rejected, deliberately, so this allow-list and the +SDK's cannot disagree about one URL. + +⚠️ **Before [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) the config and +form validator had no loopback exemption** — the *runtime* already tolerated an `http://` URL as an +already-stored `client_id`, which is precisely the asymmetry that issue is about; there was simply no +way to get such a value past validation and into `client.json`. So driving this flow meant standing +up a throwaway self-signed HTTPS listener to hold the document and setting +`NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* environment so its own fetch of that document +would succeed. That workaround is no longer needed — if you find it in a script or an older note, +delete it. + +⚠️ **Use the canonical spelling of the host.** `http://127.1/…` and `http://2130706433/…` are accepted +by the validator (and by the SDK) because both canonicalize to `127.0.0.1` — but a CIMD `client_id` +is compared as a **string** by the authorization server against the URL it dereferenced, so an +exotic spelling can fail that comparison on a server that normalizes differently than the one here. +Paste the origin the fixture announced rather than an equivalent you typed yourself. + +⚠️ **Clear OAuth state before the run if you have connected to this fixture before.** Tokens and the +registered client persist in `~/.mcp-inspector/storage/oauth.json` independently of the install-wide +CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant can +carry a run that the registration path never actually completed, which is the same +"it connected, therefore CIMD worked" trap `supportDCR: false` exists to close. Use **Clear OAuth +state and disconnect** (Server Settings → Authorization), or point `MCP_STORAGE_DIR` at a throwaway +directory, which additionally survives a restarted fixture having forgotten a client it once issued. + +With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. +It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the +metadata URL**, which is what CIMD means and what distinguishes it from a DCR-issued +`test_client_…`. On the broken build it read `Dynamic (DCR)` for exactly this flow +([#2242](https://github.com/modelcontextprotocol/inspector/issues/2242)). + +⚠️ **`clientMetadata.redirectUris` must list the callback for the port you are running.** The +Inspector's browser redirect is `/oauth/callback` and the CLI/TUI's is +`http://127.0.0.1:6276/oauth/callback`; the authorization server checks the incoming `redirect_uri` +against this list. The shipped fixture lists **6274** (the default), **6330** and **6276**; on any +other port the flow fails with `Invalid redirect_uri`, which reads like a CIMD problem and is not one. +Add your port to the config rather than debugging the registration path. + +This fixture has **no** fixed-`issuerUrl` hazard, unlike several of the ones above: it configures no +`issuerUrl`, and the document's `client_id` is derived from the request it was fetched over — query +string included — so a server that walked to another port on `EADDRINUSE` still publishes a +`client_id` equal to the URL you fetched, and the integration test drives it on a harness-chosen port +for exactly that reason. The fixed-port dependency that *does* bite is `redirect_uris`, above. + +## An OAuth endpoint that never answers + +Two OAuth-protected servers (combined AS + resource, DCR, refresh tokens) that are ordinary in every respect but one: a single endpoint **accepts the request and withholds the response forever**. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. + +- `oauth-stalled-token-http.json` stalls `POST /oauth/token`, so the token exchange and the refresh — which share that endpoint — both hang. +- `oauth-stalled-discovery-http.json` stalls `GET /.well-known/oauth-protected-resource`, so the stall happens before a client has any authorization server to talk to. + +**What to do.** Connect to either from the Inspector and start an OAuth flow. Each should fail with an `OAuthRequestTimeoutError` naming the endpoint it gave up on and the budget it used, after **30 seconds** — `DEFAULT_OAUTH_REQUEST_TIMEOUT_MS`. That wait is not configurable from the UI or from a config file: `InspectorClient` passes the constant straight into its transport chain and passes no budget at all on the auth chain, so both take the default. Budget it for a full 30s of waiting per attempt. Every *other* endpoint on the same server answers normally, so the failure points at one call rather than at the server. + +**What the broken build did.** Before [#2319](https://github.com/modelcontextprotocol/inspector/issues/2319), every OAuth-path call went out with no `AbortSignal` at all and these fixtures produced an unbounded spinner rather than an error — and a silent one, because a connect-time auth error deliberately holds the connection status at `connecting` on the theory that a redirect is about to end the attempt. The stall is server-side, in the Inspector's own Node process, so browser devtools showed no pending request while a socket sat established and idle. + +**Why the fixture exists at all.** #2319's own tests inject a `fetch` stub, which settles on the client side and cannot produce an established idle socket — the precise state the issue is about. Until [#2382](https://github.com/modelcontextprotocol/inspector/issues/2382) added `oauth.stallEndpoints`, nothing in the repo could make those five timeouts fire. + +**Configuring your own.** Any config's `oauth` block takes `stallEndpoints` — any of `protected-resource-metadata`, `as-metadata`, `authorize`, `token`, `revoke`, `register` — plus an optional `stallMs` to answer *late* instead of never. Endpoints are named by the **call**, not the path, because the token exchange and the refresh share `/oauth/token` and two of the documents sit at configurable paths. An unrecognized name throws when the server starts rather than being ignored, so a typo cannot quietly produce a fixture that answers normally. + ## Revoking tokens on clear (RFC 7009) `oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 752ecc12..520d6b9a 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -268,6 +268,8 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th ## Environment variables +This table maps v1 names to v2. Defaults, accepted values, and the variables with no v1 counterpart are in [Environment variables](./environment-variables.md). + | v1 | v2 | Notes | | ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | @@ -277,11 +279,13 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th | `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | | `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | | `DANGEROUSLY_OMIT_AUTH` | same | | -| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| `MCP_AUTO_OPEN_ENABLED` | same | Also governs opening the OAuth page in the CLI (`true` opens it even when stderr is not a TTY). The TUI does not read it | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | | — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) | | — | `MCP_APP_ORIGIN_PORT` | Dedicated app-origin server port (fixed `6278` by default), used only by an MCP App whose UI resource declares `_meta.ui.domain` | +| — | `MCP_SANDBOX_FULL_ADDRESS` | Public MCP Apps sandbox URL advertised instead of the bind-derived one, for a reverse proxy (#1862). The v2 counterpart of v1's `MCP_PROXY_FULL_ADDRESS`, but refused when it shares the Inspector's origin | +| — | `MCP_APP_ORIGIN_FULL_ADDRESS` | Public origin for `_meta.ui.domain` app documents, for a reverse proxy (#1862) | | — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | | — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | diff --git a/eslint.config.js b/eslint.config.js index df50a329..9990e974 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -101,6 +101,7 @@ export default defineConfig([ files: [ "test-servers/src/**/*.{ts,tsx,mts,cts}", "vitest.shared.mts", + "vitest.setup.shared.mts", "eslint.config.js", ], extends: [js.configs.recommended, tseslint.configs.recommended], @@ -121,6 +122,7 @@ export default defineConfig([ "core/**/*.{ts,tsx}", "test-servers/src/**/*.{ts,tsx,mts,cts}", "vitest.shared.mts", + "vitest.setup.shared.mts", ], ...typeAware, }, diff --git a/package-lock.json b/package-lock.json index 337f87b0..65ffe271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.6.0", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.6.0", + "version": "2.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/server": "2.0.0", "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", @@ -40,7 +40,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "4.1.11", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", @@ -49,7 +49,7 @@ "semver": "^7.8.5", "typescript": "~5.9.3", "typescript-eslint": "^8.65.0", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "engines": { "node": ">=22.19.0" @@ -656,9 +656,9 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.0.tgz", + "integrity": "sha512-a6tXzFcIbIIdnqumQ7W8Oxd8W/KAPkAKYpoxpD9nDgxZ24ywgxG5pK/8G9W5pOFUygS7gWHQhPv8cwRB44/8yg==", "license": "MIT", "workspaces": [ "examples/*" @@ -670,12 +670,17 @@ "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, "react": { "optional": true }, @@ -684,47 +689,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", @@ -1625,14 +1589,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1646,8 +1610,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1656,16 +1620,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1674,13 +1638,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1701,9 +1665,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1714,13 +1678,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1728,14 +1692,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1744,9 +1708,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1754,13 +1718,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1820,24 +1784,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -3510,13 +3456,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4038,9 +3977,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4942,9 +4881,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -5256,19 +5195,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5296,12 +5235,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5534,16 +5473,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index 6a18ee07..15423643 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.6.0", + "version": "2.7.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", @@ -38,7 +38,9 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", - "local:gate": "npm run validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", + "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", + "local:gate:stages": "npm run local:validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", + "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", "local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", @@ -46,18 +48,20 @@ "verify:skills": "node scripts/verify-skills.mjs", "verify:skills:cli": "node scripts/verify-skills-cli.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate:guards": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && npm run test:scripts", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + "verify:test-timeouts": "node scripts/verify-test-timeouts.mjs", "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared", "lint:core": "eslint \"core/**/*.{ts,tsx}\" --max-warnings 0", - "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js --max-warnings 0", + "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js --max-warnings 0", "format:core": "prettier --write \"core/**/*.{ts,tsx}\"", "format:check:core": "prettier --check \"core/**/*.{ts,tsx}\"", "format:scripts": "prettier --write \"scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check:scripts": "prettier --check \"scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", - "format:shared": "prettier --write \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", - "format:check:shared": "prettier --check \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", + "format:shared": "prettier --write \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", + "format:check:shared": "prettier --check \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", "format": "npm run format:core && npm run format:scripts && npm run format:shared && cd clients/web && npm run format && cd ../cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", "validate:cli": "cd clients/cli && npm run validate", "validate:tui": "cd clients/tui && npm run validate", @@ -90,7 +94,7 @@ "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/server": "2.0.0", "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", @@ -117,7 +121,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "4.1.11", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", @@ -126,6 +130,6 @@ "semver": "^7.8.5", "typescript": "~5.9.3", "typescript-eslint": "^8.65.0", - "vitest": "4.1.10" + "vitest": "4.1.11" } } diff --git a/scripts/gate-lease.mjs b/scripts/gate-lease.mjs new file mode 100644 index 00000000..f07e5971 --- /dev/null +++ b/scripts/gate-lease.mjs @@ -0,0 +1,564 @@ +#!/usr/bin/env node +/** + * Run a command under a machine-wide lease, so that concurrent `npm run + * local:gate` runs in different worktrees queue instead of contending (#2339). + * + * **Two things go wrong when gates overlap, and both were measured** on + * `v2/main` at `aa56551b` (M3, 8 logical cores). A quiet gate took 257s and + * passed. Two started together in separate worktrees: one **failed** at 279s + * — `smoke:web:chromium` found its fixed port (6298) held by the other gate's + * copy of the same smoke — and the survivor took 338s, 1.3x, with the machine + * half-free for its last minute. So the first problem is deterministic, not a + * load effect: the web smokes bind fixed ports (6296–6299, plus the sandbox + * and app-origin ports), so two gates reaching the same smoke together produce + * a red run whose diff cannot have caused it — exactly the failure #2338 + * exists to stop. The second is load: with three or four sessions (#2323 + * measured sustained load averages of 50–70) every test budget is the wrong + * number. Run back to back, the same two gates finish green in ~2x257s, and + * each is measured against the quiet baseline every other #2338 aspect is + * tuned to. So a second gate waits. + * + * **Why a lease and not a load threshold.** Two sessions each polling "is the + * machine quiet yet?" deadlock on each other — neither ever clears, since each + * is the reason the other is waiting (#2323). A lease *grants*: exactly one + * waiter wins the `mkdir`, runs, and releases; the rest keep asking. There is + * nothing to wait *for* except a release, and a release always comes — see the + * crash case below. + * + * **Why `proper-lockfile`.** `core/auth/node/file-lock.ts` (#2082) records why + * a hand-rolled election loses to it: `mkdir` is atomic, a live holder + * refreshes the lock's mtime at `stale / 2` for as long as it lives, and a + * holder that dies without releasing — a killed terminal, an OOM'd session — + * stops refreshing, goes stale, and is taken over by the next waiter. That is + * the whole "how does a crashed holder release" answer, and it is the + * library's, not ours. The file-lock module's caveats about stale takeover + * being non-single-winner apply here too and matter less: the worst case is + * two gates running at once, which is exactly today's behaviour. + * + * **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 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 + * 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 + * not free — `lint` is type-aware and `build` is a full Vite bundle — and a + * per-stage lease would let two gates interleave into the same 2.5x. One + * lease around the whole run is the smallest thing that keeps the measured + * baseline meaningful. + * + * Usage: `node scripts/gate-lease.mjs [args...]`. The command runs + * with inherited stdio in its own process group, so a signal to this process + * stops the whole tree — `npm run` nests four deep and `sh` forwards nothing — + * and the lease is released before this process exits with `128 + signal`. + */ + +import { spawn } from "node:child_process"; +import nodeFs, { + existsSync, + mkdirSync, + readFileSync, + rmSync, + rmdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { constants as osConstants, tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +// CJS-only package; a default import is the shape every loader agrees on. +import properLockfile from "proper-lockfile"; +import { winShellArgs } from "./lib/win-shell-args.mjs"; + +/** Set (to anything but `0` or empty) to run without taking the lease. */ +export const SKIP_ENV = "INSPECTOR_SKIP_GATE_LEASE"; + +/** Overrides where the lease lives. Mostly a test seam; also a shared-box escape. */ +export const DIR_ENV = "INSPECTOR_GATE_LEASE_DIR"; + +/** + * How long a lease may go unrefreshed before a waiter may take it over. + * + * `proper-lockfile` refreshes the lock's mtime at `stale / 2`, so this is not + * "how long a gate may run" — it is how long after a holder *dies* the queue + * stays blocked. 30s rather than the library's 10s default, on purpose: the + * refresh is a timer in an otherwise idle process, and the cost of a timer + * firing late (a laptop waking from sleep, an event loop starved by the very + * load this exists to manage) is a false takeover — two gates running at once, + * the thing being prevented — whereas the cost of the longer window is 20 + * more seconds of waiting after a crash, against a gate that takes minutes. + */ +export const STALE_MS = 30_000; + +/** How often a waiter re-asks. The acquire is one `mkdir`; this is not a hot loop. */ +export const POLL_MS = 2_000; + +/** How often a waiter says it is still waiting, so a long queue is visibly alive. */ +export const PROGRESS_MS = 60_000; + +/** + * How long a waiter waits before giving up. + * + * This 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. A dead holder releases within {@link STALE_MS}, so it + * expires against a live gate that has hung, a dead holder's lock directory + * that could not be removed, or a queue of healthy gates deeper than the + * budget covers — about ten at ~4.5 minutes each. The right outcome in every + * case is a loud failure naming whichever gate holds the lease at that moment + * rather than another process joining the pile; an unbounded wait would be a + * task that looks like progress and can never succeed. Keep this in step with + * the lease section of docs/quality-gate.md (#2354), which owns the prose. + */ +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) { + if (env[DIR_ENV]) return path.resolve(env[DIR_ENV]); + return path.join(env.XDG_RUNTIME_DIR || tmpdir(), "mcp-inspector-gate-lease"); +} + +/** + * The lease target: `proper-lockfile` locks `.lock` beside it and + * never opens the target itself, so the target doubles as the holder record + * (pid, worktree, start time) a waiter prints. It need not exist to be locked + * (`realpath: false` below). + */ +export function leaseTarget(dir) { + return path.join(dir, "local-gate"); +} + +/** The lock directory `proper-lockfile` creates for {@link leaseTarget}. */ +export function lockPathOf(dir) { + return `${leaseTarget(dir)}.lock`; +} + +/** `INSPECTOR_SKIP_GATE_LEASE=0` and an empty value both mean "not skipped". */ +export function isSkipped(env = process.env) { + const value = env[SKIP_ENV]; + return value !== undefined && value !== "" && value !== "0"; +} + +/** `95s` → `1m35s`; sub-minute values stay in seconds. */ +export function formatDuration(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; +} + +/** + * The exit code to report for a finished child: its own, or `128 + signal` + * (the shell convention) when a signal ended it. `code` and `signal` are + * mutually exclusive on a real ChildProcess exit. + */ +export function exitCodeFor({ code, signal }) { + if (typeof code === "number") return code; + return 128 + (osConstants.signals[signal] ?? 0); +} + +/** The holder record the current holder wrote, or `null` if unreadable or malformed. */ +export function readHolder(dir) { + try { + const record = JSON.parse(readFileSync(leaseTarget(dir), "utf8")); + if ( + typeof record?.pid === "number" && + typeof record?.cwd === "string" && + typeof record?.startedAt === "number" + ) { + return record; + } + } catch { + // Missing, unreadable, or not JSON — all mean "nothing to report". + } + return null; +} + +/** One line a waiter can act on: who holds it, where, and for how long. */ +export function describeHolder(holder, now = Date.now()) { + if (holder === null) return "another local:gate"; + return `pid ${holder.pid} in ${holder.cwd}, running for ${formatDuration(now - holder.startedAt)}`; +} + +/** + * A lock directory's identity: what changes when it is removed and recreated. + * `ino` and birth time rather than mtime, which the holder's own refresh + * rewrites legitimately; `null` when it cannot be read. + */ +function identify(lockPath) { + try { + const stat = statSync(lockPath); + return { ino: stat.ino, birthtimeMs: stat.birthtimeMs }; + } catch { + return null; + } +} + +/** + * A `proper-lockfile` `fs` shim whose directory removal refuses to delete a + * lock that is no longer the one this process created. + * + * The library's stale takeover is not single-winner (see + * `core/auth/node/file-lock.ts`, whose guard this mirrors): a holder whose + * refresh timer was starved past {@link STALE_MS} can have its directory + * replaced by a waiter, and its `release()` — and the library's `signal-exit` + * handler — would then `rmdir` the *winner's* lock by path, letting a third + * gate in beside the winner. Every removal the library performs goes through + * this object, so guarding here covers both paths. Identity is captured in + * the `mkdir` callback, the moment the directory becomes ours; `owned.id` + * stays `null` until then so a stale directory the library removes on the + * way to acquiring passes through untouched. + * + * `base` is the filesystem the shim delegates to — `node:fs` in real runs, and + * a deliberately slow one in the test that pins #2369. + */ +function guardedFs(lockPath, owned, onRefused, base = nodeFs) { + const mine = () => { + if (owned.id === null) return true; + const now = identify(lockPath); + if (now === null) return false; + return now.ino === owned.id.ino && now.birthtimeMs === owned.id.birthtimeMs; + }; + const removeIfMine = () => { + if (!mine()) { + onRefused(); + return; + } + rmdirSync(lockPath); + }; + return { + fs: { + ...base, + mkdir: (p, cb) => + base.mkdir(p, (err) => { + if (!err) owned.id = identify(lockPath); + cb(err); + }), + // Reported as success when refused, so the library forgets the lock + // either way rather than handing its exit handler a stale record. + rmdir: (_p, cb) => { + try { + removeIfMine(); + cb(null); + } catch (err) { + cb(err); + } + }, + rmdirSync: () => removeIfMine(), + }, + mine, + }; +} + +/** + * How to spawn `command args` on this platform. Shell-free everywhere but + * Windows, where `npm` is `npm.cmd` and needs `cmd.exe` to start at all + * (Node refuses shell-free `.cmd` spawns), so the arguments are quoted for + * it. The process group that lets one signal reach the whole tree is a POSIX + * notion, hence `detached` only there. + */ +export function spawnSpec(command, args, platform = process.platform) { + const win32 = platform === "win32"; + return { + command, + args: winShellArgs(args, platform), + shell: win32, + detached: !win32, + }; +} + +/** + * Send `signal` to the child's whole process group (it was spawned as a group + * leader), falling back to the child alone where groups are unavailable or the + * group is already gone. + */ +function signalTree(child, signal) { + try { + if (process.platform !== "win32") { + process.kill(-child.pid, signal); + return; + } + } catch { + // ESRCH: the group is already gone, or the child never became a leader. + } + try { + child.kill(signal); + } catch { + // Already exited; nothing to signal. + } +} + +/** + * Take the lease, waiting for a holder to release or go stale. + * + * Resolves with `{ release, waited }`, or `null` when a lock cannot be created + * here at all — the caller then runs unleased. Throws only when the wait + * budget is exhausted against a live holder. + * + * `waited` is the time spent queued behind a holder, and is `0` whenever the + * first attempt succeeded — however long that attempt took. An uncontended + * lock call on a loaded machine can outlast `pollMs`, and measuring the whole + * call reported such a run as having waited (#2369). + */ +async function acquireLease({ dir, fs, log, pollMs, progressMs, maxWaitMs }) { + const target = leaseTarget(dir); + const startedWaiting = Date.now(); + let lastProgress = startedWaiting; + let announced = false; + for (;;) { + try { + const release = await properLockfile.lock(target, { + realpath: false, + stale: STALE_MS, + retries: 0, + fs, + // The library's default throws from a timer with no caller on the + // stack, which would take the *holder* down mid-gate. A compromised + // lease means another gate is now running alongside this one — worth + // saying, not worth killing a gate that is otherwise fine. + onCompromised: (err) => + log( + `gate-lease: another process took the lease over while this gate was running (${err.message}); continuing without it.`, + ), + }); + // `announced` is set on the first failed attempt, so it is exactly + // "this call looped". + return { release, waited: announced ? Date.now() - startedWaiting : 0 }; + } catch (err) { + // `ELOCKED` is not the only "someone holds it": a stale directory the + // library could not remove (`ENOTEMPTY`, `EACCES`, `EROFS`) surfaces as + // an ordinary error, and running unleased beside whatever holds it is + // the overlap this exists to prevent. So the discriminator is the + // directory: if it exists, wait; only a lock that could not be created + // at all is a reason to degrade. + if (err?.code !== "ELOCKED" && !existsSync(lockPathOf(dir))) { + log( + `gate-lease: could not take the lease at ${lockPathOf(dir)} (${err?.message ?? err}); running without it.`, + ); + return null; + } + } + const waited = Date.now() - startedWaiting; + if (!announced) { + announced = true; + log( + `gate-lease: ${describeHolder(readHolder(dir))} holds the gate lease; waiting for it to finish so the two do not contend. ${SKIP_ENV}=1 runs anyway.`, + ); + } else if (Date.now() - lastProgress >= progressMs) { + lastProgress = Date.now(); + log( + `gate-lease: still waiting (${formatDuration(waited)}) on ${describeHolder(readHolder(dir))}.`, + ); + } + if (waited >= maxWaitMs) { + throw new Error( + `gate-lease: gave up after ${formatDuration(waited)} — ${describeHolder(readHolder(dir))} still holds ${lockPathOf(dir)}. If that gate is hung, stop it; ${SKIP_ENV}=1 runs without the lease.`, + ); + } + await delay(pollMs); + } +} + +/** + * Run `command args` holding the lease, and resolve with the exit code to + * report. Signals to this process stop the child's whole tree first and are + * reported as `128 + signal` after the lease is released. + * + * Every knob is injectable so the tests can drive a real lock, a real child + * and a real signal without waiting real minutes. + * + * @param {object} opts + * @param {string} opts.command + * @param {string[]} opts.args + * @param {string} [opts.dir] lease directory; default {@link leaseDir} + * @param {NodeJS.ProcessEnv} [opts.env] + * @param {(line: string) => void} [opts.log] + * @param {number} [opts.pollMs] + * @param {number} [opts.progressMs] + * @param {number} [opts.maxWaitMs] + * @param {number} [opts.graceMs] SIGTERM → SIGKILL escalation on a signal + * @param {typeof nodeFs} [opts.fs] filesystem the lock is taken through + * @param {import("node:child_process").StdioOptions} [opts.stdio] + * @returns {Promise} + */ +export async function runUnderLease({ + command, + args, + dir = leaseDir(), + env = process.env, + log = (line) => console.error(line), + pollMs = POLL_MS, + progressMs = PROGRESS_MS, + maxWaitMs = MAX_WAIT_MS, + graceMs = 5_000, + fs = nodeFs, + stdio = "inherit", +}) { + let release = null; + let waited = 0; + // Filled in by the shim the moment the lock directory is ours. + const owned = { id: null }; + const guarded = guardedFs( + lockPathOf(dir), + owned, + () => + log( + "gate-lease: the lease was taken over by another gate while this one ran, so its lock was left alone rather than removed.", + ), + fs, + ); + if (isSkipped(env)) { + log(`gate-lease: ${SKIP_ENV} is set; running without the lease.`); + } else { + let usable = true; + try { + mkdirSync(dir, { recursive: true }); + } catch (err) { + usable = false; + log( + `gate-lease: could not create ${dir} (${err?.message ?? err}); running without the lease.`, + ); + } + if (usable) { + const lease = await acquireLease({ + dir, + fs: guarded.fs, + log, + pollMs, + progressMs, + maxWaitMs, + }); + if (lease !== null) ({ release, waited } = lease); + } + if (release !== null) { + if (waited > 0) { + log(`gate-lease: acquired after ${formatDuration(waited)}.`); + } + try { + writeFileSync( + leaseTarget(dir), + JSON.stringify({ + pid: process.pid, + cwd: process.cwd(), + startedAt: Date.now(), + }), + ); + } catch { + // Best effort: the record only improves a waiter's message. + } + } + } + + const startedRunning = Date.now(); + // Release on every way out — a spawn failure included, or the lock would + // sit held for STALE_MS with no gate running behind it. + const finishLease = async () => { + if (release === null) return; + // The record is only meaningful while the lock beside it is held; a + // waiter reading a fresh lock must not be told about the previous + // holder — unless the lock is no longer ours, in which case the record + // is the winner's too. + try { + if (guarded.mine()) rmSync(leaseTarget(dir), { force: true }); + } catch { + // Diagnostic only; never let it stand between a finished gate and the + // release below. + } + try { + await release(); + } catch (err) { + // `ERELEASED` means the library's own refresh tick already found the + // lock taken over and dropped it; the directory there now is the + // winner's live lock, and `onCompromised` has already said so. + if (err?.code === "ERELEASED") return; + log( + `gate-lease: could not release the lease (${err?.message ?? err}). A waiter takes it over once it is ${formatDuration(STALE_MS)} stale — unless whatever blocked this removal persists, in which case remove ${lockPathOf(dir)} by hand.`, + ); + // Not "released after": the lock may still be blocking the queue. + return; + } + log( + `gate-lease: released after ${formatDuration(Date.now() - startedRunning)}${waited > 0 ? ` (waited ${formatDuration(waited)} first)` : ""}.`, + ); + }; + + // Every signal a terminal or a supervisor sends to end a run: Ctrl-C, a + // plain kill, a closed terminal, and Ctrl-\ — the child is in its own + // group, so any of these left unhandled would end this process and + // orphan the gate behind a lease that then goes stale under it. + const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]; + let stoppedBy = null; + let escalation = null; + let handlers = []; + let outcome; + // Everything from here to the child's exit is inside the `try`, so a + // synchronous failure to even start it — an argument `winShellArgs` + // refuses, a spawn option Node rejects — releases the lease the same way + // an asynchronous spawn error does. + try { + const spec = spawnSpec(command, args); + const child = spawn(spec.command, spec.args, { + stdio, + env, + shell: spec.shell, + // Its own group, so one signal reaches every descendant. See the header. + detached: spec.detached, + }); + const onSignal = (signal) => { + if (stoppedBy !== null) return; + stoppedBy = signal; + log(`gate-lease: received ${signal}; stopping the gate.`); + signalTree(child, signal); + escalation = setTimeout(() => { + log( + `gate-lease: the gate did not exit within ${graceMs}ms of ${signal}; sending SIGKILL.`, + ); + signalTree(child, "SIGKILL"); + }, graceMs); + }; + handlers = signals.map((signal) => { + const handler = () => onSignal(signal); + process.on(signal, handler); + return [signal, handler]; + }); + outcome = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + } finally { + clearTimeout(escalation); + for (const [signal, handler] of handlers) process.off(signal, handler); + await finishLease(); + } + return stoppedBy !== null + ? exitCodeFor({ code: null, signal: stoppedBy }) + : exitCodeFor(outcome); +} + +/** `node scripts/gate-lease.mjs [args...]` */ +export async function main(argv = process.argv.slice(2)) { + const [command, ...args] = argv; + if (!command) { + console.error("usage: node scripts/gate-lease.mjs [args...]"); + return 2; + } + try { + return await runUnderLease({ command, args }); + } catch (err) { + console.error(err?.message ?? err); + return 1; + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = await main(); +} diff --git a/scripts/gate-lease.test.mjs b/scripts/gate-lease.test.mjs new file mode 100644 index 00000000..0a687466 --- /dev/null +++ b/scripts/gate-lease.test.mjs @@ -0,0 +1,500 @@ +// Tests for the local:gate lease (#2339). The pure helpers are table-driven; +// the lease itself is exercised against a REAL `proper-lockfile` lock, a real +// child process and — for the signal path — a real signal to a real wrapper +// process, each against a throwaway lease directory so no test can queue +// behind (or block) a developer's actual gate. Run via `npm run test:scripts` +// (node:test; the root has no vitest harness). + +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import nodeFs from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import { + DIR_ENV, + SKIP_ENV, + STALE_MS, + describeHolder, + exitCodeFor, + formatDuration, + isSkipped, + leaseDir, + leaseTarget, + lockPathOf, + main, + readHolder, + runUnderLease, + spawnSpec, +} from "./gate-lease.mjs"; + +const SCRIPT = fileURLToPath(new URL("./gate-lease.mjs", import.meta.url)); + +/** + * A fresh lease directory per test, so tests never contend with each other. + * Each one is recorded so the suite can remove it once every test is done + * (#2346): one hook, rather than a try/finally in each of the fourteen tests, + * and it runs whether a test passed, failed, or threw before its own cleanup. + */ +const createdDirs = []; +function freshDir() { + const dir = mkdtempSync(join(tmpdir(), "gate-lease-test-")); + createdDirs.push(dir); + return dir; +} + +// Runs once all tests in the file have finished. Every test that starts a +// child (or the wrapper) awaits its exit before asserting, so by now nothing +// is holding a lock inside any of these directories, and a stray lock or +// holder record a test left on purpose is just a directory to remove. +after(() => { + for (const dir of createdDirs) rmSync(dir, { recursive: true, force: true }); +}); + +function collectLog() { + const lines = []; + return { lines, log: (line) => lines.push(line) }; +} + +/** Run `node -e ` under the lease with quiet stdio and fast knobs. */ +function runNode(source, opts = {}) { + return runUnderLease({ + command: process.execPath, + args: ["-e", source], + stdio: "ignore", + pollMs: 25, + progressMs: 60_000, + ...opts, + }); +} + +/** Poll until `predicate()` holds or `timeoutMs` passes. */ +async function waitFor(predicate, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + assert.ok(Date.now() < deadline, "condition not met in time"); + await delay(25); + } +} + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +test("leaseDir: explicit override, then XDG_RUNTIME_DIR, then tmpdir", () => { + assert.equal(leaseDir({ [DIR_ENV]: "/x/y" }), "/x/y"); + assert.equal( + leaseDir({ XDG_RUNTIME_DIR: "/run/user/1" }), + join("/run/user/1", "mcp-inspector-gate-lease"), + ); + assert.equal(leaseDir({}), join(tmpdir(), "mcp-inspector-gate-lease")); + // 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`); +}); + +test("isSkipped: unset, empty and `0` mean the lease is on", () => { + for (const [env, expected] of [ + [{}, false], + [{ [SKIP_ENV]: "" }, false], + [{ [SKIP_ENV]: "0" }, false], + [{ [SKIP_ENV]: "1" }, true], + [{ [SKIP_ENV]: "true" }, true], + ]) { + assert.equal(isSkipped(env), expected, JSON.stringify(env)); + } +}); + +test("formatDuration: seconds under a minute, m/ss above", () => { + for (const [ms, expected] of [ + [0, "0s"], + [-500, "0s"], + [4_400, "4s"], + [59_499, "59s"], + [60_000, "1m00s"], + [95_000, "1m35s"], + [3_605_000, "60m05s"], + ]) { + assert.equal(formatDuration(ms), expected, String(ms)); + } +}); + +test("exitCodeFor: the child's code, else 128 + signal", () => { + assert.equal(exitCodeFor({ code: 0, signal: null }), 0); + assert.equal(exitCodeFor({ code: 7, signal: null }), 7); + assert.equal(exitCodeFor({ code: null, signal: "SIGTERM" }), 143); + assert.equal(exitCodeFor({ code: null, signal: "SIGINT" }), 130); + assert.equal(exitCodeFor({ code: null, signal: "SIGNOPE" }), 128); +}); + +test("spawnSpec: shell-free and grouped on POSIX; cmd.exe-quoted on Windows", () => { + // `npm` is `npm.cmd` on Windows and cannot be started without a shell, and + // a shell re-parses any argument holding a space — the same pair of rules + // `scripts/install-clients.mjs` and `scripts/lib/win-shell-args.mjs` encode. + const args = ["run", "local:gate:stages", "a b"]; + assert.deepEqual(spawnSpec("npm", args, "darwin"), { + command: "npm", + args, + shell: false, + detached: true, + }); + assert.deepEqual(spawnSpec("npm", args, "linux").detached, true); + const win = spawnSpec("npm", args, "win32"); + assert.equal(win.shell, true); + assert.equal(win.detached, false); + assert.deepEqual(win.args, ["run", "local:gate:stages", '"a b"']); +}); + +test("readHolder/describeHolder: a missing or malformed record is not an error", () => { + const dir = freshDir(); + assert.equal(readHolder(dir), null); + assert.equal(describeHolder(null), "another local:gate"); + writeFileSync(leaseTarget(dir), "not json"); + assert.equal(readHolder(dir), null); + writeFileSync(leaseTarget(dir), JSON.stringify({ pid: "12", cwd: "/w" })); + assert.equal(readHolder(dir), null); + const record = { pid: 12, cwd: "/w", startedAt: 1_000 }; + writeFileSync(leaseTarget(dir), JSON.stringify(record)); + assert.deepEqual(readHolder(dir), record); + assert.equal( + describeHolder(record, 96_000), + "pid 12 in /w, running for 1m35s", + ); +}); + +test("two concurrent runs serialize: the second starts after the first ends", async () => { + const dir = freshDir(); + const marks = join(dir, "marks.log"); + const { lines, log } = collectLog(); + const body = (label) => + `const fs=require("fs");const f=${JSON.stringify(marks)};` + + `fs.appendFileSync(f,"${label} start "+Date.now()+"\\n");` + + `setTimeout(()=>fs.appendFileSync(f,"${label} end "+Date.now()+"\\n"),300)`; + const first = runNode(body("a"), { dir, log }); + // Give the first run the lock — and its holder record, which is written + // just after — before the second asks, so which one waits, and what it is + // told, are not themselves races the assertions have to allow for. + await waitFor(() => existsSync(leaseTarget(dir))); + const second = runNode(body("b"), { dir, log }); + assert.deepEqual(await Promise.all([first, second]), [0, 0]); + + const marksByLabel = Object.fromEntries( + readFileSync(marks, "utf8") + .trim() + .split("\n") + .map((line) => line.split(" ")) + .map(([label, phase, at]) => [`${label} ${phase}`, Number(at)]), + ); + assert.ok( + marksByLabel["b start"] >= marksByLabel["a end"], + `b started (${marksByLabel["b start"]}) before a ended (${marksByLabel["a end"]})`, + ); + // The waiter said who it was waiting on, named the bypass, and reported the + // wait when it finally acquired; the uncontended run said nothing on entry. + assert.equal( + lines.filter((l) => l.includes("holds the gate lease")).length, + 1, + ); + assert.match( + lines.find((l) => l.includes("holds the gate lease")), + /pid \d+ in .+, running for/, + ); + assert.match( + lines.find((l) => l.includes("holds the gate lease")), + new RegExp(`${SKIP_ENV}=1`), + ); + assert.equal( + lines.filter((l) => l.startsWith("gate-lease: acquired after")).length, + 1, + ); + assert.equal( + lines.filter((l) => l.startsWith("gate-lease: released after")).length, + 2, + ); + assert.ok(!existsSync(lockPathOf(dir)), "the lock is released at the end"); + assert.ok(!existsSync(leaseTarget(dir)), "the holder record goes with it"); +}); + +test("an uncontended run whose lock call is slow does not report a wait (#2369)", async () => { + const dir = freshDir(); + const { lines, log } = collectLog(); + const pollMs = 25; + // A loaded machine, made deterministic: the lock directory's creation + // alone outlasts several polls, though nobody else holds the lease. + const slowFs = { + ...nodeFs, + mkdir: (p, cb) => setTimeout(() => nodeFs.mkdir(p, cb), pollMs * 4), + }; + assert.equal(await runNode("", { dir, log, pollMs, fs: slowFs }), 0); + assert.deepEqual( + lines.filter((l) => l.startsWith("gate-lease: acquired after")), + [], + ); + const released = lines.filter((l) => + l.startsWith("gate-lease: released after"), + ); + assert.equal(released.length, 1); + assert.doesNotMatch(released[0], /waited/); +}); + +test("the child's exit code is the run's exit code", async () => { + const dir = freshDir(); + assert.equal(await runNode("process.exit(7)", { dir, log: () => {} }), 7); +}); + +test("a stale lock (its holder died) is taken over, not waited on", async () => { + const dir = freshDir(); + mkdirSync(lockPathOf(dir), { recursive: true }); + const dead = new Date(Date.now() - STALE_MS - 1_000); + utimesSync(lockPathOf(dir), dead, dead); + const { lines, log } = collectLog(); + assert.equal(await runNode("", { dir, log }), 0); + assert.equal( + lines.filter((l) => l.includes("holds the gate lease")).length, + 0, + ); + assert.ok(!existsSync(lockPathOf(dir))); +}); + +test("a stale lock that cannot be removed is waited on, not bypassed", async () => { + const dir = freshDir(); + // Stale by mtime, but with a file inside — the library's takeover `rmdir` + // fails ENOTEMPTY, which is not ELOCKED. Something still holds the path, + // so degrading to an unleased run here would be the overlap this prevents. + mkdirSync(lockPathOf(dir), { recursive: true }); + writeFileSync(join(lockPathOf(dir), "stray"), ""); + const dead = new Date(Date.now() - STALE_MS - 1_000); + utimesSync(lockPathOf(dir), dead, dead); + const { lines, log } = collectLog(); + await assert.rejects(runNode("", { dir, log, maxWaitMs: 0 }), /gave up/); + assert.equal( + lines.filter((l) => l.includes("running without")).length, + 0, + lines.join("\n"), + ); + assert.ok(existsSync(lockPathOf(dir))); +}); + +test("a command that cannot be spawned still releases the lease", async () => { + const dir = freshDir(); + const { log } = collectLog(); + await assert.rejects( + runUnderLease({ + command: join(dir, "no-such-binary"), + args: [], + dir, + log, + stdio: "ignore", + pollMs: 25, + }), + { code: "ENOENT" }, + ); + assert.ok( + !existsSync(lockPathOf(dir)), + "the lease must not outlive the failed spawn", + ); + assert.ok(!existsSync(leaseTarget(dir))); +}); + +test("a command that fails synchronously before it starts still releases the lease", async () => { + const dir = freshDir(); + const { log } = collectLog(); + // `spawn` validates its arguments synchronously: a non-array `args` throws + // before any child exists, the same class as `winShellArgs` refusing a `%` + // on Windows. That throw must not strand the lock until stale takeover. + await assert.rejects( + runUnderLease({ + command: process.execPath, + args: "-e", + dir, + log, + stdio: "ignore", + pollMs: 25, + }), + { code: "ERR_INVALID_ARG_TYPE" }, + ); + assert.ok(!existsSync(lockPathOf(dir))); + assert.ok(!existsSync(leaseTarget(dir))); +}); + +test("a lock replaced mid-run (stale takeover) is the winner's, and is left alone", async () => { + const dir = freshDir(); + const { lines, log } = collectLog(); + const run = runNode("setTimeout(() => {}, 400)", { dir, log }); + await waitFor(() => existsSync(leaseTarget(dir))); + // What a waiter does after our refresh timer was starved past STALE_MS: + // remove our directory and create its own. A new inode, so the guard can + // tell it is not ours — and must not `rmdir` it on our way out, or a third + // gate would run beside the winner. + rmSync(lockPathOf(dir), { recursive: true }); + mkdirSync(lockPathOf(dir)); + writeFileSync( + leaseTarget(dir), + JSON.stringify({ pid: 1, cwd: "/winner", startedAt: Date.now() }), + ); + assert.equal(await run, 0); + assert.ok( + existsSync(lockPathOf(dir)), + "the winner's lock survived our release", + ); + assert.equal(readHolder(dir)?.cwd, "/winner", "and so did its record"); + assert.ok( + lines.some((l) => l.includes("taken over by another gate")), + lines.join("\n"), + ); +}); + +test("a live lock that never releases fails the wait loudly, naming the holder", async () => { + const dir = freshDir(); + // A fresh lock directory nobody refreshes is indistinguishable from a live + // holder for STALE_MS, which is longer than this test is prepared to wait. + mkdirSync(lockPathOf(dir), { recursive: true }); + writeFileSync( + leaseTarget(dir), + JSON.stringify({ + pid: 424242, + cwd: "/some/worktree", + startedAt: Date.now(), + }), + ); + const { lines, log } = collectLog(); + await assert.rejects(runNode("", { dir, log, maxWaitMs: 0 }), (err) => { + assert.match(err.message, /gave up after/); + assert.match(err.message, /pid 424242 in \/some\/worktree/); + assert.match(err.message, new RegExp(`${SKIP_ENV}=1`)); + return true; + }); + assert.equal( + lines.filter((l) => l.includes("holds the gate lease")).length, + 1, + ); + // The lock was somebody else's; giving up must not remove it. + assert.ok(existsSync(lockPathOf(dir))); +}); + +test("a waiter reports progress while it waits", async () => { + const dir = freshDir(); + mkdirSync(lockPathOf(dir), { recursive: true }); + const { lines, log } = collectLog(); + await assert.rejects( + runNode("", { dir, log, maxWaitMs: 120, pollMs: 30, progressMs: 0 }), + /gave up/, + ); + assert.ok( + lines.some((l) => l.startsWith("gate-lease: still waiting")), + lines.join("\n"), + ); +}); + +test(`${SKIP_ENV}=1 runs without touching the lease`, async () => { + const dir = freshDir(); + const { lines, log } = collectLog(); + const env = { ...process.env, [SKIP_ENV]: "1" }; + assert.equal(await runNode("", { dir, log, env }), 0); + assert.match(lines.join("\n"), new RegExp(`${SKIP_ENV} is set`)); + assert.ok(!existsSync(lockPathOf(dir))); +}); + +test("a lease directory that cannot exist degrades to an unleased run", async () => { + const parent = freshDir(); + const file = join(parent, "a-file"); + writeFileSync(file, ""); + const { lines, log } = collectLog(); + assert.equal(await runNode("", { dir: join(file, "nested"), log }), 0); + assert.match( + lines.join("\n"), + /could not create .*; running without the lease/, + ); +}); + +test("main: no command is a usage error", async () => { + const { error } = console; + const lines = []; + console.error = (line) => lines.push(line); + try { + assert.equal(await main([]), 2); + } finally { + console.error = error; + } + assert.match(lines.join("\n"), /usage:/); +}); + +// Ctrl-C and a plain kill are the obvious ones; Ctrl-\ (SIGQUIT) is the one +// that was missed (Copilot), and it matters for the same reason: the child +// is in its own process group, so a signal the wrapper does not handle ends +// the wrapper alone and orphans the gate. +for (const [signal, code] of [ + ["SIGTERM", 143], + ["SIGQUIT", 131], +]) { + test(`${signal} to the wrapper stops the whole tree, releases the lease and exits ${code}`, async () => { + const dir = freshDir(); + const pidFile = join(dir, "grandchild.pid"); + // The gate stand-in: starts a descendant of its own (as `npm run` does, + // four levels deep), records both pids, then idles forever unless + // signalled. The descendant is what proves the whole *tree* is stopped — + // a regression to `child.kill()` or a non-detached spawn would leave it + // running and fail below. + const idle = + `const d = require("child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });` + + `require("fs").writeFileSync(${JSON.stringify(pidFile)}, process.pid + " " + d.pid);` + + `setInterval(() => {}, 1000)`; + const wrapper = spawn( + process.execPath, + [SCRIPT, process.execPath, "-e", idle], + { + env: { ...process.env, [DIR_ENV]: dir }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + wrapper.stderr.on("data", (chunk) => (stderr += chunk)); + const exited = new Promise((resolve) => + wrapper.once("exit", (code, signal) => resolve({ code, signal })), + ); + + let tree = []; + try { + await waitFor(() => existsSync(pidFile)); + tree = readFileSync(pidFile, "utf8").split(" ").map(Number); + assert.equal(tree.length, 2); + for (const pid of tree) assert.ok(isAlive(pid), `pid ${pid} started`); + assert.ok(existsSync(lockPathOf(dir)), "the wrapper held the lease"); + } finally { + // On every path — a failed setup assertion included — the wrapper is + // told to stop, so an idle grandchild can never outlive the suite. + wrapper.kill(signal); + } + const outcome = await exited; + assert.deepEqual(outcome, { code, signal: null }, stderr); + for (const pid of tree) + await waitFor(() => !isAlive(pid)).catch(() => { + process.kill(pid, "SIGKILL"); + assert.fail( + `pid ${pid} outlived the wrapper — the tree was not stopped`, + ); + }); + assert.ok( + !existsSync(lockPathOf(dir)), + "the lease was released on the way out", + ); + assert.match(stderr, new RegExp(`received ${signal}; stopping the gate`)); + }); +} diff --git a/scripts/lib/announced-child.mjs b/scripts/lib/announced-child.mjs index 52bc18a1..71f314d7 100644 --- a/scripts/lib/announced-child.mjs +++ b/scripts/lib/announced-child.mjs @@ -23,6 +23,34 @@ import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; +/** + * The readiness budget and poll interval, stated once (#2333) — the same shape + * as `render-smoke.mjs`'s `DEFAULTS`, and for the same reason: a budget that + * lives in a parameter default is one nobody revisits. + * + * `timeoutMs` is a ceiling on a poll, not a sleep: the wait returns on the + * first poll that sees the announcement, so a passing start pays only the + * child's real boot time plus, at most, one `pollMs`. Every smoke that needs a + * test server goes through this, which makes it the widest of the smoke + * budgets — and the one with the most headroom. Measured on the composable + * test server (`server-composable.js --config mcp-app-http`) through this + * helper with a 25ms poll, ten starts on a machine at load average 15: boot to + * announce was 135ms median, 137ms max. 30s is more than two hundred times + * that; what it has to absorb is not a slow server but a machine several + * gates deep, where a Node process can take seconds to reach its first line. + * + * `pollMs` is the passing path's cost. The same measurement at the default + * interval landed at 251–254ms every run, which is one interval exactly: the + * child announces well inside the first poll, so a start pays ~250ms of which + * ~115ms is waiting to look. That is under a second across a whole gate, so + * it is stated rather than tuned — lowering it buys nothing anyone would + * notice and costs a tighter scan loop on the child's output. + */ +export const DEFAULTS = Object.freeze({ + timeoutMs: 30_000, + pollMs: 250, +}); + /** * @param {object} opts * @param {string} opts.command Executable to spawn. @@ -34,8 +62,8 @@ import { setTimeout as delay } from "node:timers/promises"; * Called synchronously with the child immediately after spawn, before any * waiting. This is the caller's teardown handle. * @param {string} opts.what Noun used in error messages ("MCP test server"). - * @param {number} [opts.timeoutMs] Readiness budget (default 30s). - * @param {number} [opts.pollMs] Poll interval (default 250ms). + * @param {number} [opts.timeoutMs] Readiness budget (default `DEFAULTS.timeoutMs`). + * @param {number} [opts.pollMs] Poll interval (default `DEFAULTS.pollMs`). * @returns {Promise<{ child: import("node:child_process").ChildProcess, match: RegExpMatchArray }>} */ export async function startAnnouncedChild({ @@ -45,8 +73,8 @@ export async function startAnnouncedChild({ pattern, onSpawn, what, - timeoutMs = 30_000, - pollMs = 250, + timeoutMs = DEFAULTS.timeoutMs, + pollMs = DEFAULTS.pollMs, }) { const child = spawn(command, args, { cwd, diff --git a/scripts/lib/browser-timeouts.mjs b/scripts/lib/browser-timeouts.mjs new file mode 100644 index 00000000..87e383c2 --- /dev/null +++ b/scripts/lib/browser-timeouts.mjs @@ -0,0 +1,59 @@ +/** + * Playwright wall-clock budgets for the web smokes (#2323). + * + * Before this, `smoke-web-tabs.mjs`, `smoke-web-elicitation.mjs` and + * `smoke-web-browser.mjs` carried 19 budgets between them across three + * independently-chosen scales, with no config anywhere to raise — 17 locator + * operations plus a `page.goto` and a `waitForLoadState`. They are the + * same three or four decisions repeated, so they are named once here and the + * scripts import them — the same shape `render-smoke.mjs`'s `DEFAULTS` already + * has, and for the same reason: a budget nobody can find is a budget nobody + * revisits. The shared flow helpers (`deep-link-connect.mjs`, + * `mcp-app-flow.mjs`) default their budgets to these too (#2333); they used to + * carry `ui`/`roundTrip` as literals of their own. + * + * Three of the four are ceilings on a poll rather than sleeps — `ui`, + * `roundTrip` and `nested` each return the instant their locator condition + * holds, so a passing smoke pays only the time the app actually took. What they + * absorb is a smoke running against a cold `dist/` build on a machine already + * carrying other agent sessions' gates. + * + * ⚠️ `bestEffort` is the exception and is not free: its expiry is caught and + * ignored, so a smoke that never reaches network idle spends the full budget on + * an otherwise **passing** run. That is why it is the smallest of the four, and + * why it should stay that way. + * + * ⚠️ Raising any of these does delay the report when the thing waited on never + * arrives — a larger budget is a longer wait before the failure. What the + * launcher-death race in `smoke-web-browser.mjs` and the `waitForStage` + * diagnostics in the other two buy is a better *eventual* message, naming a + * cause instead of a locator; they do not make the wait shorter. Weigh both + * when changing a value here. + */ + +export const BROWSER_TIMEOUTS = Object.freeze({ + /** + * Load the page, or act on a control the app is already showing — a click on + * a rendered tab or list row, a wait for the first meaningful frame. What it + * absorbs is the browser's own scheduling under load, not a server. + */ + ui: 30_000, + /** + * Wait for something that only appears after a round trip to an MCP server: + * an elicitation modal, a tool result panel. Longer than `ui` because a test + * server has to boot, negotiate and answer before the DOM can change. + */ + roundTrip: 45_000, + /** + * Wait on a sub-element of something already on screen — a row inside a + * populated panel, a section of an open accordion. The enclosing wait has + * already paid for the round trip, so this only covers the render. + */ + nested: 15_000, + /** + * A wait whose expiry is caught and ignored, so it must stay short: it is + * pure added latency on every passing run. `networkidle` is the only one — + * the Google Fonts request may never idle on a restricted network. + */ + bestEffort: 5_000, +}); diff --git a/scripts/lib/browser-timeouts.test.mjs b/scripts/lib/browser-timeouts.test.mjs new file mode 100644 index 00000000..bdab308b --- /dev/null +++ b/scripts/lib/browser-timeouts.test.mjs @@ -0,0 +1,40 @@ +/** + * Pins the invariants of `browser-timeouts.mjs` — a constants module, so what + * is testable about it is the shape of the values rather than any logic. + * + * Worth pinning anyway, because the values are the module's whole contract and + * a smoke cannot report a bad one. A budget that arrived as `undefined` or a + * string reaches Playwright as "no timeout" or NaN and a smoke then hangs + * against the job's own ceiling rather than failing with a cause. And the + * ordering below IS the semantics documented on each key: the names say what + * each budget has to absorb, so a `nested` wait outliving its enclosing + * `roundTrip` one would mean the names had stopped describing the values. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; + +test("every budget is a positive finite number of milliseconds", () => { + const entries = Object.entries(BROWSER_TIMEOUTS); + assert.ok(entries.length > 0); + for (const [name, ms] of entries) { + assert.equal(typeof ms, "number", `${name} is not a number`); + assert.ok(Number.isFinite(ms) && ms > 0, `${name} is not a usable budget`); + } +}); + +test("the object is frozen, so a smoke cannot mutate a shared budget", () => { + assert.ok(Object.isFrozen(BROWSER_TIMEOUTS)); +}); + +test("the budgets are ordered by what each one has to absorb", () => { + // Not decoration: the ordering IS the semantics documented on each key. A + // `nested` wait that outlived its enclosing `roundTrip` one, or a + // `bestEffort` wait long enough to be felt on every passing run, would mean + // the names had stopped describing the values. + assert.ok(BROWSER_TIMEOUTS.bestEffort < BROWSER_TIMEOUTS.nested); + assert.ok(BROWSER_TIMEOUTS.nested < BROWSER_TIMEOUTS.ui); + assert.ok(BROWSER_TIMEOUTS.ui < BROWSER_TIMEOUTS.roundTrip); +}); diff --git a/scripts/lib/deep-link-connect.mjs b/scripts/lib/deep-link-connect.mjs index aaa7dfe4..17b51bc1 100644 --- a/scripts/lib/deep-link-connect.mjs +++ b/scripts/lib/deep-link-connect.mjs @@ -25,8 +25,17 @@ * Takes only the slice of Playwright's `page` it uses (`goto`, `locator` → * `waitFor`/`getAttribute`), which is what lets its failure branches be * unit-tested against a stand-in — a smoke only ever drives its happy path. + * + * The two budgets default to `BROWSER_TIMEOUTS` rather than restating them + * (#2333): `goto` and the status element attaching are the page loading and + * painting (`ui`), and `data-status="connected"` only appears after a round + * trip to the MCP server (`roundTrip`). `driveAppFlow` in `mcp-app-flow.mjs` + * passes the same two through, so the two helpers cannot drift apart — which + * is the point: they used to carry the same literals by hand. */ +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; + /** * The deep link that connects to `mcpUrl`. * @@ -57,8 +66,8 @@ export async function connectViaDeepLink({ page, url, expectDeepLink = true, - gotoTimeoutMs = 30_000, - connectTimeoutMs = 45_000, + gotoTimeoutMs = BROWSER_TIMEOUTS.ui, + connectTimeoutMs = BROWSER_TIMEOUTS.roundTrip, }) { const response = await page.goto(url, { waitUntil: "domcontentloaded", diff --git a/scripts/lib/deep-link-connect.test.mjs b/scripts/lib/deep-link-connect.test.mjs index effd1457..113aff37 100644 --- a/scripts/lib/deep-link-connect.test.mjs +++ b/scripts/lib/deep-link-connect.test.mjs @@ -17,24 +17,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; import { buildConnectDeepLink, connectViaDeepLink, } from "./deep-link-connect.mjs"; -/** Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. */ +/** + * Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. + * `gotoTimeouts` and `waits` record the budget each call was given, so a test + * can pin the defaults without a real browser. + */ function fakePage(plan, { gotoStatus = 200 } = {}) { const gotos = []; + const gotoTimeouts = []; + const waits = {}; return { gotos, - goto: async (url) => { + gotoTimeouts, + waits, + goto: async (url, opts) => { gotos.push(url); + gotoTimeouts.push(opts?.timeout); return { ok: () => gotoStatus === 200, status: () => gotoStatus }; }, locator: (selector) => { const entry = plan[selector] ?? {}; return { - waitFor: async () => { + waitFor: async (opts) => { + waits[selector] = opts?.timeout; if (entry.waitFails) throw new Error(`timeout: ${selector}`); }, getAttribute: async (name) => entry.attrs?.[name] ?? null, @@ -96,6 +107,18 @@ describe("connectViaDeepLink", () => { assert.deepEqual(page.gotos, ["http://x/"]); }); + it("defaults each budget to the shared BROWSER_TIMEOUTS entry (#2333)", async () => { + // These used to be 30_000 / 45_000 literals of the helper's own — the same + // values `browser-timeouts.mjs` names `ui` and `roundTrip`, kept in step by + // hand. Pinning the defaults to the constants is what makes a raise there + // reach this helper and `driveAppFlow` at once. + const page = fakePage(happyPlan()); + await connectViaDeepLink({ page, url: "http://x/" }); + assert.deepEqual(page.gotoTimeouts, [BROWSER_TIMEOUTS.ui]); + assert.equal(page.waits[STATUS], BROWSER_TIMEOUTS.ui); + assert.equal(page.waits[CONNECTED], BROWSER_TIMEOUTS.roundTrip); + }); + it("reports a non-200 document instead of waiting on selectors", async () => { await assert.rejects( connectViaDeepLink({ diff --git a/scripts/lib/mcp-app-flow.mjs b/scripts/lib/mcp-app-flow.mjs index 4a8082ff..4253bb29 100644 --- a/scripts/lib/mcp-app-flow.mjs +++ b/scripts/lib/mcp-app-flow.mjs @@ -30,6 +30,7 @@ import { join } from "node:path"; import { startAnnouncedChild } from "./announced-child.mjs"; +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; import { buildConnectDeepLink, connectViaDeepLink, @@ -167,6 +168,13 @@ export function buildAppDeepLink({ * Takes only the small slice of Playwright's `page` it uses (`goto`, `locator` * → `waitFor`/`getAttribute`/`count`), which is what lets its failure paths be * unit-tested against a stand-in — a smoke only ever exercises its happy path. + * + * The three budgets are `BROWSER_TIMEOUTS` entries, not restated literals + * (#2333). The first two are handed straight to `connectViaDeepLink`, so the + * two helpers share one pair by construction. `ready` is a `roundTrip` too: + * the widget has to load inside the sandbox and answer through the bridge + * before the attribute can flip, which is the same shape as the connect wait, + * not a render of something already on screen (`nested`). */ export async function driveAppFlow({ page, @@ -174,9 +182,9 @@ export async function driveAppFlow({ expectDeepLink = true, what = "app", extraDiagnostics = null, - gotoTimeoutMs = 30_000, - connectTimeoutMs = 45_000, - readyTimeoutMs = 45_000, + gotoTimeoutMs = BROWSER_TIMEOUTS.ui, + connectTimeoutMs = BROWSER_TIMEOUTS.roundTrip, + readyTimeoutMs = BROWSER_TIMEOUTS.roundTrip, }) { // 1-2. Navigated, the deep link accepted by the token gate, and connected. await connectViaDeepLink({ diff --git a/scripts/lib/mcp-app-flow.test.mjs b/scripts/lib/mcp-app-flow.test.mjs index 1c331c8c..985ae79a 100644 --- a/scripts/lib/mcp-app-flow.test.mjs +++ b/scripts/lib/mcp-app-flow.test.mjs @@ -16,6 +16,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; import { buildAppDeepLink, driveAppFlow, @@ -23,9 +24,15 @@ import { sandboxProxyPageFor, } from "./mcp-app-flow.mjs"; -/** Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. */ +/** + * Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. + * `waits` records the budget each `waitFor` was given, so a test can pin the + * defaults without a real browser. + */ function fakePage(plan, { gotoStatus = 200 } = {}) { + const waits = {}; return { + waits, goto: async () => ({ ok: () => gotoStatus === 200, status: () => gotoStatus, @@ -33,7 +40,8 @@ function fakePage(plan, { gotoStatus = 200 } = {}) { locator: (selector) => { const entry = plan[selector] ?? {}; return { - waitFor: async () => { + waitFor: async (opts) => { + waits[selector] = opts?.timeout; if (entry.waitFails) throw new Error(`timeout: ${selector}`); }, getAttribute: async (name) => entry.attrs?.[name] ?? null, @@ -110,6 +118,17 @@ describe("driveAppFlow", () => { await driveAppFlow({ page: fakePage(happyPlan()), url: "http://x/" }); }); + it("shares its connect budgets with connectViaDeepLink and waits a roundTrip for ready (#2333)", async () => { + // The connect pair is handed straight through, so `ui`/`roundTrip` here + // are the same two `connectViaDeepLink` defaults to; `ready` is a round + // trip of its own (sandbox load + bridge handshake), not a `nested` render. + const page = fakePage(happyPlan()); + await driveAppFlow({ page, url: "http://x/" }); + assert.equal(page.waits[STATUS], BROWSER_TIMEOUTS.ui); + assert.equal(page.waits[CONNECTED], BROWSER_TIMEOUTS.roundTrip); + assert.equal(page.waits[READY], BROWSER_TIMEOUTS.roundTrip); + }); + it("reports a non-200 document instead of waiting on selectors", async () => { await assert.rejects( driveAppFlow({ diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index 0427c45d..9ea15bf4 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -25,14 +25,17 @@ export function tokenize(command) { * ` references within a single manifest's `scripts`, plus npm's implicit * `pre`/`post` lifecycle hooks (npm runs those around `` * without an explicit `npm run`, so a gate moved into e.g. `prevalidate` is - * still reached). A gate harvested from a script that nothing reachable from + * still reached). Every spelling npm accepts for `run` is followed — + * `run-script` is the canonical command and `run`, `rum` and `urn` are its + * aliases — so a chain written as `npm run-script validate:web` reaches the + * same scripts as `npm run validate:web` (Copilot, #2341). A gate harvested from a script that nothing reachable from * `entry` invokes gates nothing, so callers restrict to this set to assert "CI * actually runs this", not merely "the script exists". */ export function reachableScripts(scripts, entry = "validate") { const reached = new Set(); const queue = [entry]; - const runRef = /npm run ([\w:-]+)/g; + const runRef = /npm (?:run-script|run|rum|urn) ([\w:-]+)/g; while (queue.length > 0) { const name = queue.shift(); if (reached.has(name)) continue; @@ -47,6 +50,16 @@ export function reachableScripts(scripts, entry = "validate") { return reached; } +/** The lease wrapper `local:gate` runs under (#2339); see `scripts/gate-lease.mjs`. */ +export const GATE_LEASE_WRAPPER = "node scripts/gate-lease.mjs "; + +/** `node scripts/gate-lease.mjs npm run X` → `npm run X`; anything else unchanged. */ +function unwrapGateLease(segment) { + return segment.startsWith(GATE_LEASE_WRAPPER) + ? segment.slice(GATE_LEASE_WRAPPER.length).trim() + : segment; +} + /** The command strings of every script reachable from the root `validate`. */ function rootReachedCommands(rootScripts) { return [...reachableScripts(rootScripts)] @@ -105,6 +118,12 @@ export function rootReachesScript(rootScripts, scriptName) { * flags, as are npm's implicit `pre`/`post` hooks. Only the target is matched * exactly, which suits the argument-less scripts a vouch asks about. * + * One wrapper is transparent: `node scripts/gate-lease.mjs npm run X` runs + * `npm run X` under the local:gate lease (#2339) and nothing else, so a vouch + * about what `local:gate` runs looks through it. It is matched by its exact + * leading tokens — a wrapper with flags, or any other wrapper, still hides the + * invocation, because this helper cannot know what an arbitrary wrapper does. + * * @param {Record} scripts * @param {string} entry * @param {string} target @@ -121,7 +140,9 @@ export function scriptChainRuns(scripts, entry, target) { if (typeof scripts?.[hook] === "string") queue.push(hook); const body = scripts?.[name]; if (typeof body !== "string") continue; - for (const segment of body.split(/\n|;|&&/).map((part) => part.trim())) { + for (const segment of body + .split(/\n|;|&&/) + .map((part) => unwrapGateLease(part.trim()))) { if (segment === `npm run ${target}`) return true; const tokens = segment.split(/\s+/); if (tokens[0] === "npm" && tokens[1] === "run" && tokens[2]) { diff --git a/scripts/lib/npm-scripts.test.mjs b/scripts/lib/npm-scripts.test.mjs index a9408a6e..3dd7b2c0 100644 --- a/scripts/lib/npm-scripts.test.mjs +++ b/scripts/lib/npm-scripts.test.mjs @@ -48,6 +48,25 @@ test("reachableScripts: follows `npm run` refs and pre/post hooks (r10)", () => assert.ok(!reached.has("unrelated")); }); +test("reachableScripts: follows every npm spelling of `run` (#2341)", () => { + // `run-script` is the canonical command; `run`, `rum` and `urn` are its + // aliases. A chain written in any of them reaches the same scripts, so a + // guard that only followed `npm run` would let `npm run-script validate:web` + // reintroduce what it exists to forbid (Copilot). + const scripts = { + gate: "npm run-script a && npm rum b && npm urn c && npm run d", + a: "noop", + b: "noop", + c: "noop", + d: "noop", + e: "noop", + }; + const reached = reachableScripts(scripts, "gate"); + for (const name of ["a", "b", "c", "d"]) + assert.ok(reached.has(name), `npm spelling reaching ${name}`); + assert.ok(!reached.has("e")); +}); + test("reachableScripts: a `prevalidate`-hosted typecheck is reachable (r10)", () => { const scripts = { validate: "npm run build && npm run test", diff --git a/scripts/lib/pty.mjs b/scripts/lib/pty.mjs index d311ba68..74be1485 100644 --- a/scripts/lib/pty.mjs +++ b/scripts/lib/pty.mjs @@ -108,6 +108,26 @@ export function ptyCommand({ command, args = [], flavor }) { throw new Error(`unknown script(1) flavor: ${flavor}`); } +/** + * How long `script --version` gets to exit (#2333). + * + * A ceiling on a synchronous spawn, not a sleep: the probe returns the moment + * the process exits, and `script --version` exits at once on every flavor — + * BSD `script` with a usage error, util-linux and busybox with a version line. + * Measured here on darwin, twenty spawns with the machine at load average 4 + * (recent peak 19): 1.3ms median, 3.6ms max. Five seconds is over a thousand + * times that, and what it has to absorb is process creation on a machine + * several gates deep, not the command. + * + * The number matters less than what its expiry means. `spawnSync` reports a + * timeout in the same `error` slot as ENOENT, and `probeScriptVersion` used to + * read both as "no `script(1)`" — so a starved probe turned `smoke:tui` into a + * documented **skip**, exit 0, with a message blaming the machine's PATH. A + * false skip on the one pre-push gate is worse than a false failure, which is + * why the timeout is now told apart and thrown instead (see below). + */ +export const SCRIPT_PROBE_TIMEOUT_MS = 5_000; + /** * Probe the local `script(1)`: is it there, and what does it say about itself? * @@ -124,23 +144,45 @@ export function ptyCommand({ command, args = [], flavor }) { * answers `illegal option -- -` plus its usage on stderr — which is exactly the * evidence `scriptFlavorFor` reads. Only `error` means "could not run it". * - * @param {(cmd: string, args: string[]) => { stdout?: string, stderr?: string, error?: Error }} [runner] + * A **timeout** is not unavailability either, and it is the one `error` that + * must not be folded into "not available": `script(1)` was found and started, + * and the machine did not let it finish. Reporting that as "no `script(1)` on + * PATH" would make `smoke:tui` skip — a green exit on a run that tested + * nothing — so it throws, naming the budget, and the caller fails loudly. + * + * @param {(cmd: string, args: string[]) => { stdout?: string, stderr?: string, error?: Error & { code?: string } }} [runner] * Injected for tests; defaults to a real `spawnSync`. * @returns {{ available: boolean, output: string }} + * @throws {Error} when the probe timed out (`error.code === "ETIMEDOUT"`). */ export function probeScriptVersion( runner = (cmd, args) => - spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }), + spawnSync(cmd, args, { + encoding: "utf8", + timeout: SCRIPT_PROBE_TIMEOUT_MS, + }), ) { + let r; try { - const r = runner("script", ["--version"]); - // `error` covers ENOENT, EACCES and the timeout; a missing result at all - // means the runner told us nothing, which is not evidence of a working one. - if (!r || r.error) return { available: false, output: "" }; - return { available: true, output: `${r.stdout ?? ""}${r.stderr ?? ""}` }; - } catch { - return { available: false, output: "" }; + r = runner("script", ["--version"]); + } catch (err) { + // A runner that throws outright (injected, or a future spawn shape) is + // normalized onto the same path as one that returns `{ error }`, so the + // timeout check below applies to both shapes — a thrown ETIMEDOUT must not + // slip back into "unavailable" (Copilot, #2333). + r = { error: err }; } + if (r?.error?.code === "ETIMEDOUT") { + throw new Error( + `\`script --version\` did not exit within ${SCRIPT_PROBE_TIMEOUT_MS}ms — ` + + "a starved or hung probe, not a missing `script(1)`, so smoke:tui " + + "must not skip on it", + ); + } + // `error` covers ENOENT and EACCES; a missing result at all means the runner + // told us nothing, which is not evidence of a working one. + if (!r || r.error) return { available: false, output: "" }; + return { available: true, output: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } /** @@ -151,11 +193,16 @@ export function probeScriptVersion( * invocation" send the reader somewhere different — so the failure carries a * `reason` rather than being a bare `null` the caller has to narrate. * + * A probe that **timed out** is neither — it throws through here untouched, + * because "the machine would not run `script`" is a failure to report, not a + * reason to skip (see `probeScriptVersion`). + * * @param {object} [opts] * @param {string} [opts.platform] * @param {() => { available: boolean, output: string }} [opts.probe] * @returns {{ ok: true, flavor: string, wrap: (spec: { command: string, args?: string[] }) => { command: string, args: string[] } } * | { ok: false, reason: string }} + * @throws {Error} when the probe timed out. */ export function resolvePtyWrapper({ platform = process.platform, diff --git a/scripts/lib/pty.test.mjs b/scripts/lib/pty.test.mjs index ceb1fbcb..3f4d4fc0 100644 --- a/scripts/lib/pty.test.mjs +++ b/scripts/lib/pty.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { ptyCommand, probeScriptVersion, + SCRIPT_PROBE_TIMEOUT_MS, resolvePtyWrapper, scriptFlavorFor, shellQuote, @@ -120,7 +121,7 @@ test("shellQuote survives an embedded single quote", () => { assert.equal(shellQuote("plain"), "'plain'"); }); -test("probeScriptVersion combines both streams and never throws", () => { +test("probeScriptVersion combines both streams and reads a runner failure as unavailable", () => { assert.deepEqual( probeScriptVersion(() => ({ stdout: "out ", stderr: "err" })), { available: true, output: "out err" }, @@ -168,13 +169,46 @@ test("probeScriptVersion treats result.error as unavailable, not as empty output probeScriptVersion(() => ({ error: enoent, stdout: "", stderr: "" })), { available: false, output: "" }, ); - // The timeout shape reports the same way. + // An `error` with no recognisable code is still "could not run it". assert.equal( - probeScriptVersion(() => ({ error: new Error("ETIMEDOUT") })).available, + probeScriptVersion(() => ({ error: new Error("EACCES") })).available, false, ); }); +// The one `error` that must NOT read as unavailable (#2333). `script(1)` was +// found and started; the machine did not let it finish. Folded into +// `available: false` it becomes a `smoke:tui` SKIP — exit 0, blaming PATH — on +// exactly the loaded machine the gate is supposed to be believable on. +test("probeScriptVersion throws on a timed-out probe instead of reporting no script(1)", () => { + const timedOut = Object.assign(new Error("spawnSync script ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + assert.throws( + () => + probeScriptVersion(() => ({ error: timedOut, stdout: "", stderr: "" })), + new RegExp(`did not exit within ${SCRIPT_PROBE_TIMEOUT_MS}ms`), + ); + // A runner that THROWS the same error is normalized onto the same path — the + // `catch` must not quietly turn a thrown ETIMEDOUT back into "unavailable". + assert.throws( + () => + probeScriptVersion(() => { + throw timedOut; + }), + /did not exit within/, + ); + // And it reaches the caller: resolvePtyWrapper must not turn it into a skip. + assert.throws( + () => + resolvePtyWrapper({ + platform: "darwin", + probe: () => probeScriptVersion(() => ({ error: timedOut })), + }), + /did not exit within/, + ); +}); + test("resolvePtyWrapper reports a missing script(1) rather than guessing", () => { // linux would otherwise be classified util-linux off the empty output. const r = resolvePtyWrapper({ diff --git a/scripts/lib/workflow-gate.mjs b/scripts/lib/workflow-gate.mjs index 1f3e175c..9a531b11 100644 --- a/scripts/lib/workflow-gate.mjs +++ b/scripts/lib/workflow-gate.mjs @@ -7,9 +7,11 @@ * two verify gates, `smoke` and the Storybook tests. `npm run smoke` covers * `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web` and * `smoke:web:chromium` — all of which BELONG there. - * - **`npm run local:gate`**, the pre-push gate, is a strict superset: it adds - * the **Firefox** engine pass, and `smoke:tui` really runs there rather than - * self-skipping. WebKit is on demand and belongs to neither tier. + * - **`npm run local:gate`**, the pre-push gate, runs every check CI runs and + * adds the **Firefox** engine pass, and `smoke:tui` really runs there rather + * than self-skipping. WebKit is on demand and belongs to neither tier. It + * runs each client's unit suite once (instrumented, under `coverage`) via + * `local:validate`, where CI's two parallel jobs run it twice (#2341). * * The narrow thing that must never reach CI is a **non-Chromium engine pass**. * `smoke:web:firefox` was placed in the local gate on purpose (#2086, diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 483f3089..5ddfc395 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -23,6 +23,11 @@ import { findWorkflowViolations, formatWorkflowViolations, } from "./workflow-gate.mjs"; +import { + GATE_LEASE_WRAPPER, + reachableScripts, + scriptChainRuns, +} from "./npm-scripts.mjs"; const repoRoot = join(import.meta.dirname, "..", ".."); const workflowDir = join(repoRoot, ".github", "workflows"); @@ -606,7 +611,138 @@ describe("the gate's name", () => { it("is `local:gate`, and runs the local-only Storybook step", () => { assert.ok(scripts["local:gate"], "the pre-push gate must exist"); - assert.match(scripts["local:gate"], /local:storybook/); + assert.match(scripts["local:gate:stages"], /local:storybook/); + }); + + it("runs its stages under the lease wrapper, and nothing else (#2339)", () => { + // The wrapper serializes gates across worktrees. It must be the ONLY thing + // `local:gate` does — a stage placed beside it would run outside the + // lease — and it must run exactly the stages script, so every assertion + // below about what the gate runs can read `local:gate:stages` directly. + assert.equal( + scripts["local:gate"], + `${GATE_LEASE_WRAPPER}npm run local:gate:stages`, + ); + assert.ok( + scriptChainRuns(scripts, "local:gate", "local:storybook"), + "the wrapper is transparent to a chain vouch", + ); + }); + + describe("runs each client's test suite once, not twice (#2341)", () => { + // CI runs each client's unit suite bare (inside `validate`) and again + // instrumented (inside `coverage`) on two PARALLEL runners, so the second + // pass is free there. The gate runs serially, where the bare pass was ~80s + // of a ~370s run re-executing exactly the files `coverage` runs a few + // minutes later. So the gate's first stage is `local:validate` — `validate` + // minus every client's `test` leg — and these pin the three properties + // that keep it honest: the gate no longer reaches a client's bare `test`, + // it still reaches every non-test check `validate` reaches, and `validate` + // itself (CI's inner loop) is untouched. + const clients = ["web", "cli", "tui", "launcher"]; + const clientScripts = Object.fromEntries( + clients.map((c) => [ + c, + JSON.parse( + readFileSync(join(repoRoot, "clients", c, "package.json"), "utf8"), + ).scripts, + ]), + ); + + it("starts from `local:validate`, and never from `validate`", () => { + assert.match(scripts["local:gate:stages"], /^npm run local:validate && /); + assert.doesNotMatch( + scripts["local:gate:stages"], + /\brun validate(?=$|[\s&;])/, + ); + // Nothing reachable from the WHOLE gate — not just its first stage — + // may run `validate` or a client's `validate:*`: those are the doors + // through which the bare `test` leg would come back, and a later stage + // could open one just as easily as the first (Copilot). + const reached = reachableScripts(scripts, "local:gate"); + assert.ok(!reached.has("validate"), "local:gate must not reach validate"); + for (const c of clients) + assert.ok( + !reached.has(`validate:${c}`), + `local:gate must not reach validate:${c}`, + ); + // Each client is visited AND its `check` is the very next command — + // matched together, so dropping one client's `npm run check` while the + // `cd` and another client's `check` remain cannot stay green (Copilot). + for (const c of clients) + assert.match( + scripts["local:validate"], + new RegExp( + `(?:cd (?:\\.\\./|clients/)${c} && npm run check|--prefix clients/${c} run check)(?=$|[\\s&;])`, + ), + `local:validate must run \`check\` in clients/${c}`, + ); + }); + + it("shares the guards and `validate:core` with `validate`", () => { + // One list of durable guards, reached by both entry points — so a guard + // added to `validate:guards` lands in CI and in the gate together. + const gate = reachableScripts(scripts, "local:validate"); + const inner = reachableScripts(scripts, "validate"); + for (const name of ["validate:guards", "validate:core"]) { + assert.ok(gate.has(name), `local:validate must reach ${name}`); + assert.ok(inner.has(name), `validate must reach ${name}`); + } + // Everything `validate` reaches at the root that is not a client + // delegation is reached by the gate too. + for (const name of inner) + if ( + name !== "validate" && + !/^validate:(web|cli|tui|launcher)$/.test(name) + ) + assert.ok(gate.has(name), `local:validate must reach ${name}`); + }); + + it("each client's `validate` is `check` plus `test`, and `check` runs no tests", () => { + for (const c of clients) { + const s = clientScripts[c]; + assert.equal( + s.validate, + "npm run check && npm run test", + `clients/${c} validate`, + ); + const fromCheck = reachableScripts(s, "check"); + for (const name of fromCheck) + assert.doesNotMatch( + name, + /^(pre|post)?test(:|$)/, + `clients/${c} check must not reach ${name}`, + ); + // `reachableScripts` follows `npm run ` only, so also reject the + // spellings that reach the suite without one: a direct `vitest`, and + // npm's built-in `npm test` / `npm t` / `npm tst` aliases (Copilot). + const checkCommands = [...fromCheck].map((n) => s[n]).join(" "); + assert.doesNotMatch( + checkCommands, + /\bvitest\b/, + `clients/${c} check must not invoke vitest`, + ); + assert.doesNotMatch( + checkCommands, + /\bnpm (?:test|t|tst)(?=$|[\s&;])/, + `clients/${c} check must not invoke \`npm test\``, + ); + // And `check` is the whole of `validate` minus the test leg: what + // `validate` reaches is exactly itself, plus what `check` reaches, plus + // what `test` reaches (its `pretest` build preambles included). + assert.deepEqual( + [...reachableScripts(s, "validate")].sort(), + [ + ...new Set([ + "validate", + ...fromCheck, + ...reachableScripts(s, "test"), + ]), + ].sort(), + `clients/${c} validate must reach exactly check + test`, + ); + } + }); }); it("has no `ci` alias, not even a deprecated one", () => { diff --git a/scripts/run-engine-smokes.test.mjs b/scripts/run-engine-smokes.test.mjs index 78aff741..6facad60 100644 --- a/scripts/run-engine-smokes.test.mjs +++ b/scripts/run-engine-smokes.test.mjs @@ -17,6 +17,7 @@ import { join } from "node:path"; import { describe, it } from "node:test"; import { ENGINE_SMOKES } from "./run-engine-smokes.mjs"; import { SUPPORTED_BROWSERS } from "./lib/headless-browser.mjs"; +import { scriptChainRuns } from "./lib/npm-scripts.mjs"; const scriptDir = import.meta.dirname; const scripts = JSON.parse( @@ -73,7 +74,8 @@ describe("every engine tier consumes ENGINE_SMOKES", () => { it("the Firefox tier goes through the runner and is in the pre-push gate", () => { assert.match(scripts["smoke:web:firefox"], /run-engine-smokes\.mjs/); - assert.match(scripts["local:gate"], /smoke:web:firefox/); + // Through the #2339 lease wrapper, which `scriptChainRuns` looks past. + assert.ok(scriptChainRuns(scripts, "local:gate", "smoke:web:firefox")); }); it("every supported engine has a `smoke:web:` script", () => { diff --git a/scripts/sdk-watch.mjs b/scripts/sdk-watch.mjs index 5dcd0a36..a55240f3 100644 --- a/scripts/sdk-watch.mjs +++ b/scripts/sdk-watch.mjs @@ -27,11 +27,12 @@ // 2. **Compare the INSTALLED version, not the declared range.** #1063 phrases // the check as "is the current version > than the one we have in our // package.json", which is exact today only because the four SDK packages -// are pinned exactly. `ext-apps` is a caret range (`^1.7.4`) whose lockfile -// already resolves higher, so comparing against the declared string would -// file an issue for a bump `npm install` has already taken. The declared -// range is still reported — it is what says whether the fix is a manifest -// edit or a lockfile refresh — but the comparison is against the lockfile. +// are pinned exactly. `ext-apps` is a caret range, so its lockfile can +// already resolve higher than the manifest's floor, and comparing against +// the declared string would file an issue for a bump `npm install` has +// already taken. The declared range is still reported — it is what says +// whether the fix is a manifest edit or a lockfile refresh — but the +// comparison is against the lockfile. // 3. **A new SDK package must not be watched silently by nobody.** The group // table is a hardcoded list, so a fifth `@modelcontextprotocol/*` package // added to the root manifest would never be checked and nothing would say @@ -136,10 +137,27 @@ export const ANALYSIS_MARKER = ""; * * forge a supersession marker so the genuine note is never posted. * * So a marker counts only when the thing carrying it was written by this - * automation. `gh issue list --json author` reports a bot with the `[bot]` - * suffix stripped and `is_bot: true`, while the REST comments endpoint reports - * `github-actions[bot]` with `type: "Bot"` — hence the normalization in both - * predicates rather than one spelling assumed. + * automation. + * + * ⚠️ **The same account has THREE spellings, and normalizing only some of them + * disables suppression silently.** That is not hypothetical — it shipped, and + * cost three identical issues on three consecutive nights (#2377): + * + * | Source | Spelling | + * | --- | --- | + * | REST comments endpoint | `github-actions[bot]`, `type: "Bot"` | + * | older `gh issue list --json author` | `github-actions`, `is_bot: true` | + * | newer `gh issue list --json author` | **`app/github-actions`** | + * + * `normalizeLogin` therefore strips an `app/` **prefix** as well as a `[bot]` + * suffix. Stripping the prefix widens nothing an outsider can claim: `/` is not + * a legal character in a GitHub username, so no human account can normalize + * onto `github-actions` — and the labels half of `isSweepAuthored` is an + * independent check regardless. + * + * A fourth spelling would break it again, and the failure mode is silence. So + * `sweepIssues` also reports any issue that carries this sweep's marker and its + * labels but fails the author check — see `warnOnUnrecognizedAuthors`. */ export const AUTOMATION_LOGIN = "github-actions"; @@ -149,6 +167,7 @@ export const SWEEP_LABELS = ["chore", "dependencies"]; const normalizeLogin = (login) => String(login ?? "") .toLowerCase() + .replace(/^app\//, "") .replace(/\[bot\]$/, ""); /** @@ -164,14 +183,90 @@ const normalizeLogin = (login) => * @returns {boolean} */ export function isSweepAuthored(issue) { + return hasSweepAuthor(issue) && hasSweepLabels(issue); +} + +/** + * The author half of `isSweepAuthored`, on its own. + * + * Split out so `warnOnUnrecognizedAuthors` can distinguish the two ways an + * issue fails the check. "Wrong labels" is the ordinary case — somebody else's + * issue that mentions the sweep. "Right labels, unrecognized author" is the + * signature of a login spelling this script does not know about, which is a + * defect in `normalizeLogin` rather than anything about the issue. + * + * @param {{author?: {login?: string, is_bot?: boolean}}} issue + * @returns {boolean} + */ +export function hasSweepAuthor(issue) { if (normalizeLogin(issue?.author?.login) !== AUTOMATION_LOGIN) return false; // `is_bot` is absent on some `gh` versions; only an explicit `false` — a human // account that happens to carry the name — is disqualifying. - if (issue?.author?.is_bot === false) return false; + return issue?.author?.is_bot !== false; +} + +/** + * The labels half of `isSweepAuthored`, on its own. + * + * This is the half an outsider cannot forge: `chore` and `dependencies` both + * need write access on the repo. + * + * @param {{labels?: Array<{name?: string}>}} issue + * @returns {boolean} + */ +export function hasSweepLabels(issue) { const names = new Set((issue?.labels ?? []).map((l) => l?.name)); return SWEEP_LABELS.every((label) => names.has(label)); } +/** + * Report issues that look like this sweep's own but whose author it does not + * recognize. + * + * ⚠️ **This is the guard against the whole class of #2377, not just its + * instance.** Suppression, analysis-retry suppression and supersession notes + * all gate on `isSweepAuthored`, and when a login spelling stops normalizing + * they do not fail — they quietly decide the sweep has never filed anything, + * and the sweep refiles the same issue every night forever. Nothing goes red, + * so the only way anyone finds out is by noticing the duplicates by hand, which + * took three nights last time. + * + * An issue carrying a valid marker **and** both write-access-only labels, whose + * author does not normalize onto `AUTOMATION_LOGIN`, is that signature. It + * cannot be produced by an outsider, because it needs the labels. + * + * Reporting rather than throwing is deliberate: a hard failure here would take + * the nightly sweep down over a cosmetic upstream rename, and the sweep's job — + * noticing an SDK release — is still worth doing while the spelling is fixed. + * + * @param {Array<{author?: {login?: string}, body?: string, labels?: Array<{name?: string}>}>} issues + * @param {(msg: string) => void} [warn] + * @returns {string[]} the unrecognized logins, deduplicated + */ +export function warnOnUnrecognizedAuthors(issues, warn = console.warn) { + const unrecognized = [ + ...new Set( + (issues ?? []) + .filter( + (issue) => + !hasSweepAuthor(issue) && + hasSweepLabels(issue) && + parseMarker(issue?.body) !== null, + ) + .map((issue) => String(issue?.author?.login ?? "")), + ), + ]; + if (unrecognized.length > 0) { + warn( + `sdk-watch: ⚠️ ${unrecognized.length} author spelling(s) carry this sweep's marker AND its ` + + `labels but do not normalize onto "${AUTOMATION_LOGIN}": ${unrecognized.map((l) => JSON.stringify(l)).join(", ")}. ` + + "Suppression, analysis retries and supersession notes are all disabled for those issues — " + + "teach `normalizeLogin` the spelling (see #2377).", + ); + } + return unrecognized; +} + /** * Was this comment written by the automation? * @@ -352,10 +447,10 @@ const cell = (value) => String(value).replace(/\|/g, "\\|"); * ⚠️ Not every bump is a manifest edit, and saying so unconditionally was wrong * for the very case this script exists to handle separately (Copilot). The four * `typescript-sdk` packages are pinned **exactly**, so any new version needs the - * manifest changed. `ext-apps` is a **range** (`^1.7.4`), so a 1.8.0 target is - * already satisfied by what `package.json` says and only `npm install` is needed - * — telling a maintainer to edit the manifest there sends them to change a line - * that is already correct. + * manifest changed. `ext-apps` is a caret **range**, so a target that is only a + * patch or minor ahead within the same major is already satisfied by what + * `package.json` says and only `npm install` is needed — telling a maintainer to + * edit the manifest there sends them to change a line that is already correct. * * A row whose declared value is not a parseable range (an unparsed dependency, * or the `(undeclared)` placeholder) counts as needing the edit: that is the @@ -561,7 +656,10 @@ function sweepIssues(repo, spawn) { if (result.status !== 0) { throw new Error(`gh issue list failed: ${(result.stderr ?? "").trim()}`); } - return JSON.parse(result.stdout || "[]") + const issues = JSON.parse(result.stdout || "[]"); + // Before filtering them away: say so if any of them are ours but unreadable. + warnOnUnrecognizedAuthors(issues); + return issues .filter(isSweepAuthored) .map((issue) => ({ ...issue, marker: parseMarker(issue.body) })) .filter((issue) => issue.marker && semver.valid(issue.marker.target)); diff --git a/scripts/sdk-watch.test.mjs b/scripts/sdk-watch.test.mjs index 3fcdb6c9..f27acdab 100644 --- a/scripts/sdk-watch.test.mjs +++ b/scripts/sdk-watch.test.mjs @@ -29,6 +29,9 @@ import { hasAnalysis, installedVersion, isSweepAuthored, + hasSweepAuthor, + hasSweepLabels, + warnOnUnrecognizedAuthors, main, needsManifestEdit, parseMarker, @@ -147,11 +150,36 @@ test("isSweepAuthored requires both the automation author and the sweep's labels labels: SWEEP_LABELS.map((name) => ({ name })), }; assert.equal(isSweepAuthored(owned), true); - // gh reports a bot with the suffix stripped; the REST API keeps it. Both spellings. - assert.equal( - isSweepAuthored({ ...owned, author: { login: "github-actions[bot]" } }), - true, - ); + // The SAME account has three spellings; all three must be accepted, or + // suppression silently stops working (#2377). See `AUTOMATION_LOGIN`. + for (const login of [ + "github-actions", // older `gh issue list --json author` + "github-actions[bot]", // REST comments endpoint + "app/github-actions", // newer `gh issue list --json author` — the #2377 miss + "App/GitHub-Actions[bot]", // nothing promises a casing or one form at a time + ]) { + assert.equal( + isSweepAuthored({ ...owned, author: { login } }), + true, + `login spelling ${JSON.stringify(login)} must be recognized as the sweep's own`, + ); + } + // The prefix strip must not become a way in for an account that is NOT ours. + // `/` is not legal in a GitHub username, so these cannot exist — but the + // predicate should not be the reason we believe that. + for (const login of [ + "app/someone", + "notapp/github-actions", + "app/github-actions-nightly", + "github-actions-nightly", + "app//github-actions", + ]) { + assert.equal( + isSweepAuthored({ ...owned, author: { login } }), + false, + `login spelling ${JSON.stringify(login)} must NOT be accepted`, + ); + } // An outsider's issue carrying a forged marker: right shape, wrong provenance. assert.equal( isSweepAuthored({ ...owned, author: { login: "someone", is_bot: false } }), @@ -174,6 +202,75 @@ test("isSweepAuthored requires both the automation author and the sweep's labels assert.equal(isSweepAuthored({}), false); }); +test("hasSweepAuthor and hasSweepLabels are the two independent halves", () => { + const owned = { + author: { login: "app/github-actions", is_bot: true }, + labels: SWEEP_LABELS.map((name) => ({ name })), + }; + assert.equal(hasSweepAuthor(owned), true); + assert.equal(hasSweepLabels(owned), true); + // Each half ignores the other's input entirely. + assert.equal(hasSweepAuthor({ ...owned, labels: [] }), true); + assert.equal( + hasSweepLabels({ ...owned, author: { login: "someone" } }), + true, + ); + assert.equal( + hasSweepLabels({ ...owned, labels: [{ name: "chore" }] }), + false, + ); + assert.equal(hasSweepAuthor({}), false); + assert.equal(hasSweepLabels({}), false); +}); + +test("warnOnUnrecognizedAuthors reports an author spelling that stops normalizing", () => { + // The #2377 signature: our own marker, our own write-access-only labels, and + // an author this script cannot read. Silent before; loud now. + const marked = `\nbody`; + const labels = SWEEP_LABELS.map((name) => ({ name })); + const warnings = []; + const found = warnOnUnrecognizedAuthors( + [ + { author: { login: "apps/github-actions" }, body: marked, labels }, + // Same unreadable spelling twice — reported once. + { author: { login: "apps/github-actions" }, body: marked, labels }, + // Readable: the whole point is that these are NOT reported. + { author: { login: "app/github-actions" }, body: marked, labels }, + { author: { login: "github-actions[bot]" }, body: marked, labels }, + // An outsider's issue quoting the marker has no labels to go with it, so + // it is an ordinary miss rather than a spelling defect — and reporting it + // would hand any commenter a way to spam the sweep's log. + { author: { login: "someone" }, body: marked, labels: [] }, + // Right author-shape and labels, but no marker of ours: not our issue. + { author: { login: "apps/github-actions" }, body: "no marker", labels }, + ], + (m) => warnings.push(m), + ); + assert.deepEqual(found, ["apps/github-actions"]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /apps\/github-actions/); + assert.match(warnings[0], /#2377/); +}); + +test("warnOnUnrecognizedAuthors stays silent when every author is readable", () => { + const marked = `\nbody`; + const labels = SWEEP_LABELS.map((name) => ({ name })); + const warnings = []; + assert.deepEqual( + warnOnUnrecognizedAuthors( + [{ author: { login: "app/github-actions" }, body: marked, labels }], + (m) => warnings.push(m), + ), + [], + ); + assert.deepEqual(warnings, []); + assert.deepEqual( + warnOnUnrecognizedAuthors([], (m) => warnings.push(m)), + [], + ); + assert.deepEqual(warnings, []); +}); + /** The parsed workflow, read from disk so the tests assert the shipped file. */ function workflowDoc() { return YAML.parse( @@ -1059,6 +1156,85 @@ test("main ignores an outsider's issue carrying the current target's marker", () ); }); +/** Run `fn` with `console.warn` captured, restoring it whatever happens. */ +function capturingWarnings(fn) { + const warnings = []; + const original = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + fn(); + } finally { + console.warn = original; + } + return warnings; +} + +test("main warns when a sweep-shaped issue's author does not normalize", () => { + // ⚠️ This is the PRODUCTION wiring, not the helper. Unit-testing + // `warnOnUnrecognizedAuthors` alone left `sweepIssues`' call to it deletable + // with the whole suite still green — so the guard against a silent + // normalization drift could itself have gone silent (#2379 review). + const spawn = fakeSpawn({ + latest: latestAt(SDK, "2.1.0"), + issues: [ + { + ...existingIssue(400, "2.1.0"), + // Labels an outsider cannot set, our own marker, and a spelling + // `normalizeLogin` has never seen: the #2377 signature exactly. + author: { login: "apps/github-actions", is_bot: true }, + }, + ], + }); + + const warnings = capturingWarnings(() => + main("o/r", spawn, noAmbientOutput()), + ); + + const drift = warnings.filter((w) => w.includes("apps/github-actions")); + assert.equal( + drift.length > 0, + true, + `expected a drift warning naming the unreadable author; got ${JSON.stringify(warnings)}`, + ); + assert.match(drift[0], /#2377/); + // And the consequence is real: unable to read its own issue, the sweep + // refiles. The warning is what makes that visible instead of silent. + assert.ok( + spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"), + ); +}); + +test("main stays silent when every sweep issue's author is readable", () => { + // The other side of the guard: a warning that fires on the ordinary path is + // noise, and a channel nobody trusts is one people learn to skim. + for (const login of [ + "github-actions", + "github-actions[bot]", + "app/github-actions", + ]) { + const spawn = fakeSpawn({ + latest: latestAt(SDK, "2.1.0"), + issues: [ + { ...existingIssue(400, "2.1.0"), author: { login, is_bot: true } }, + ], + }); + const warnings = capturingWarnings(() => + main("o/r", spawn, noAmbientOutput()), + ); + assert.deepEqual( + warnings.filter((w) => w.includes("normalize")), + [], + `login ${JSON.stringify(login)} must not be reported as a drift`, + ); + // Readable author ⇒ the marker is trusted ⇒ no refile. + assert.equal( + spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"), + false, + `login ${JSON.stringify(login)} must suppress the refile`, + ); + } +}); + test("main ignores an issue that lacks the labels only write access can set", () => { const spawn = fakeSpawn({ latest: latestAt(SDK, "2.1.0"), diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 780c09bb..ed7fc245 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -57,11 +57,14 @@ const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); // acceptable is a separate judgement rather than one inherited from a number // tuned for the other measurement. 0.5 is the weakest claim worth asserting — // the pointer is taken more often than not — and it is a floor on useful -// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. A -// well-shaped pointer clears 0.8 outright: the committed `testing` -> +// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. One +// well-shaped pointer has cleared 0.8 outright: the committed `testing` -> // `test-servers` cases measured 33% (RUNS=3) when `testing` merely classified // which work belonged to `test-servers`, and 100%/100% (RUNS=5) once #2247 -// reshaped that into an imperative step. (An intermediate build of that change +// reshaped that into an imperative step. That is ONE pointer over ONE edit that +// moved wording and placement together, so read it as an existence proof that +// the ceiling is above 0.8 — not as a general property of "well-shaped" +// pointers, and not as grounds for raising this floor (Copilot, #2264). (An intermediate build of that change // measured 100%/80%. That is NOT an example of clearing a 0.8 bar — this // threshold is compared strictly, so 80% would fail one — but it is worth // knowing as the residual noise still present at RUNS=5.) What 0.5 buys diff --git a/scripts/smoke-tui.mjs b/scripts/smoke-tui.mjs index 7f17a288..23fe2f96 100644 --- a/scripts/smoke-tui.mjs +++ b/scripts/smoke-tui.mjs @@ -93,7 +93,16 @@ if (!existsSync(launcher)) { // No `script(1)` (Windows). Running without a PTY is not a weaker check, it is // a guaranteed failure — so say why rather than report a crash as a defect. // `node-pty` is the portable answer if this ever needs to run there. -const pty = resolvePtyWrapper(); +let pty; +try { + pty = resolvePtyWrapper(); +} catch (err) { + // The probe timed out: `script(1)` exists and the machine did not let it + // finish. That is a starved gate, not a platform without a PTY — reported as + // a failure so a load spike cannot quietly turn this smoke into a skip + // (#2333). The budget and the measurement are on `SCRIPT_PROBE_TIMEOUT_MS`. + fail(err instanceof Error ? err.message : String(err)); +} if (!pty.ok) { skip( `${pty.reason} — the Ink TUI needs raw mode, which requires a real ` + diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 35018272..32355c48 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -64,6 +64,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { BROWSER_TIMEOUTS } from "./lib/browser-timeouts.mjs"; import { attachPageDiagnostics, loadBrowser, @@ -135,7 +136,7 @@ try { // authenticates without a query param. const response = await page.goto(server.baseUrl, { waitUntil: "domcontentloaded", - timeout: 30_000, + timeout: BROWSER_TIMEOUTS.ui, }); if (!response || !response.ok()) { throw new Error( @@ -145,12 +146,12 @@ try { // First meaningful frame: the always-present "Add Servers" control. await page .getByRole("button", { name: /Add Servers/ }) - .waitFor({ state: "visible", timeout: 30_000 }); + .waitFor({ state: "visible", timeout: BROWSER_TIMEOUTS.ui }); // Settle window: let lazily-evaluated chunks that throw a tick after first // paint surface before we assert a clean boot. networkidle is best-effort // (the Google-Fonts request may never idle on a restricted network). await page - .waitForLoadState("networkidle", { timeout: 5_000 }) + .waitForLoadState("networkidle", { timeout: BROWSER_TIMEOUTS.bestEffort }) .catch(() => {}); await delay(500); }; diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 6cbeaf9a..abeb4227 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -57,6 +57,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { join, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; +import { BROWSER_TIMEOUTS } from "./lib/browser-timeouts.mjs"; import { attachPageDiagnostics, loadBrowser, @@ -203,13 +204,16 @@ async function runTool(page) { // The main-view tabs are a Mantine SegmentedControl: a visually-hidden radio // plus a sibling