chore: merge v2/main into main for the v2.7.0 release
This commit is contained in:
commit
74d6e30fe5
222 changed files with 15063 additions and 1475 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
41
.github/workflows/main.yml
vendored
41
.github/workflows/main.yml
vendored
|
|
@ -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
122
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 <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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 <path>` | `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).
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue