Merge pull request #2381 from modelcontextprotocol/v2/chore/milestone-merge-v2.7.0
Some checks failed
CI / build (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / publish (push) Has been cancelled
CI / publish-github-container-registry (push) Has been cancelled

chore(release): merge v2/main into main for v2.7.0
This commit is contained in:
Cliff Hall 2026-09-16 11:06:09 -04:00 • committed by GitHub
commit 2e90a628e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
227 changed files with 16296 additions and 1540 deletions

View file

@ -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).

View file

@ -36,7 +36,7 @@ other.
| Branch | `v2/chore/<ISSUE>-bump-<X-Y-Z>`, 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.

View file

@ -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 <name>.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: [<built entry>] }` — which is still
this shape, and still the build.
- **Spawned composable HTTP — `server-composable.js --config <name>.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 <test-servers/build/test-server-stdio.js>`.
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:

View file

@ -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 <name>.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.

View file

@ -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

122
AGENTS.md
View file

@ -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 <pkg>` 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@<new>`), 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:

View file

@ -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.

View file

@ -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 (`<MCP_STORAGE_DIR>/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 (`<MCP_STORAGE_DIR>/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 <level>` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). |
| `--metadata <key=value>` | 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 <key=value>` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. |
| `--connect-timeout <ms>` | 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 <ms>` | 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 <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 <skill>` 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

View file

@ -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<SpawnResult> {
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<SpawnResult> {
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,
);
});

View file

@ -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,

View file

@ -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,
);
});

View file

@ -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();

View file

@ -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",

View file

@ -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<ParseResult> {
return n;
},
)
.option(
"--protocol-era <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 <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<ParseResult> {
verify?: boolean;
cursor?: string;
connectTimeout?: number;
protocolEra?: ServerProtocolEra;
format?: OutputFormat;
toolArgsJson?: string;
clientConfig?: string;
@ -1009,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
// `--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<string, string> | 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

View file

@ -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

View file

@ -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

View file

@ -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: <message>` 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: <message>` 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 <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 <legacy|auto|modern>` 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

View file

@ -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"
}
}

View file

@ -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

View file

@ -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"],

Some files were not shown because too many files have changed in this diff Show more