Merge branch 'v2/main' into v2/feat/2319-oauth-timeouts

This commit is contained in:
Cliff Hall 2026-09-11 09:06:08 -04:00 • committed by GitHub
commit fbb61978ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 250 additions and 18 deletions

View file

@ -17,9 +17,171 @@ and what the broken build did — is
[`docs/test-servers.md`](../../../docs/test-servers.md). This skill is how to [`docs/test-servers.md`](../../../docs/test-servers.md). This skill is how to
run one. 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 ## 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 ```sh
cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/ 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 `.tsbuildinfo` is pinned inside `build/` so that clean actually invalidates
the cache. 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 ```sh
# 1. The server, from the repo root, with the config you picked: # 1. The server, from the repo root, with the config you picked:

View file

@ -11,7 +11,7 @@ statements, functions, and branches.** That rule and the React/Mantine
conventions live in [`AGENTS.md`](../../../AGENTS.md); this skill is where a conventions live in [`AGENTS.md`](../../../AGENTS.md); this skill is where a
test goes, how to run it, and how to clear the gate. 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 **If it does, load the `test-servers` skill now — that is step one, before
choosing a location or writing a line.** 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 - **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 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 coverage gap only reachable over a real connection; reproducing a reported bug
against a server. These need the whole procedure — which showcase config, against a server. These need the whole procedure — the staleness hazard, and
which protocol era, and the staleness hazard. 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 ⚠️ **Connecting is a strong hint, not the rule.** A few integration tests
deliberately hand-roll a JSON-RPC server because the composable fixture deliberately hand-roll a JSON-RPC server because the composable fixture
*cannot* produce what they assert on — `inspectorClient-malformed-list.test.ts` *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.** ⚠️ **"A build ran" is not the dependency — using the artefact is.**
`clients/web`'s `pretest` runs `test-servers:build` before *every* unit run, so `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 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 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 fixture props, exercises a pure function or a parser, or is a smoke that touches

View file

@ -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 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 body actually points at the target — a chain through a skill that says
nothing about it is a permanent 0% with no lever. 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` ⚠️ **The gate cannot catch a description that never matches.** `verify:skills`
checks that a skill is well-formed and that its cases exist; only 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 — `skills:eval` observes whether it actually fires, and that cannot be gated —

View file

@ -201,6 +201,17 @@ ever taken.** `test-servers` scores 5/5 on its own cases and every one of them
asks for it by name; a skill only ever reached _through_ another would score a asks for it by name; a skill only ever reached _through_ another would score a
clean 100% while the hand-off silently never fired (#2204). clean 100% while the hand-off silently never fired (#2204).
⚠️ **A green hand-off number is not evidence the hand-off is USEFUL.** The case
can observe one thing — that skill B was loaded. Whether B then answers the
question the prompt actually asked is outside what any `chain` case measures, in
either direction: B can be loaded and say nothing relevant, or say something
actively wrong for the caller that arrived through A. `testing` ->
`test-servers` scored 100% while `test-servers` documented only the two-process
manual path, so every model that followed the pointer from an *integration-test*
prompt was handed the wrong half of the procedure (#2264). So when a chain case
goes green, **read B's body as the caller who arrives through A** and check that
what it says fits that caller. The number cannot do that for you.
A chained case names the ordered skills one run should load: A chained case names the ordered skills one run should load:
```json ```json
@ -285,8 +296,8 @@ license (Copilot). A strict bound of `1.0` is therefore unreachable and the
harness rejects it up front rather than failing every case. harness rejects it up front rather than failing every case.
0.5 is a floor on **useful reliability for a second-hop load**, not a claim that 0.5 is a floor on **useful reliability for a second-hop load**, not a claim that
0.8 is out of reach — a well-shaped pointer clears it outright, at 100% in the 0.8 is out of reach — the one pointer measured after reshaping cleared it
worked example below. Note that a *chain* bar of 0.8 would be a **strict** one outright, at 100% in the worked example below. Note that a *chain* bar of 0.8 would be a **strict** one
(`> 0.8`, the comparison this threshold uses; the first-move 0.8 is the (`> 0.8`, the comparison this threshold uses; the first-move 0.8 is the
inclusive `>=`), so at `RUNS=5` only a clean 5/5 would pass it — 4/5 would not. What 0.5 buys is that the column keeps carrying signal across the inclusive `>=`), so at `RUNS=5` only a clean 5/5 would pass it — 4/5 would not. What 0.5 buys is that the column keeps carrying signal across the
*range* of pointer strengths a repo actually has: a hand-off is a noisier *range* of pointer strengths a repo actually has: a hand-off is a noisier
@ -319,11 +330,18 @@ measured 100% / 80% on the full suite and 100% / 100% on a focused
noise, well short of the 67 above.) noise, well short of the 67 above.)
The transferable part is that **a pointer is followed when it reads as an action The transferable part is that **a pointer is followed when it reads as an action
with a trigger, and skimmed when it reads as a fact.** #2202 found the same with a trigger, placed where the decision is actually made.** #2202 found the
lever on a *description*'s shape; this is it applied to a body. The corollary is same lever on a *description*'s shape; this is it applied to a body.
where to put one: the top of a body is read before the model knows it needs the
second skill, so a pointer that lives only there is a pointer it has already ⚠️ **Read that as ONE lever, not two.** The #2247 edit changed the wording *and*
scrolled past by the time it matters. the placement in the same revision, and only the combination was measured, so
the run establishes the pair — it does not license attributing the rise to
imperative phrasing alone, nor to repetition alone. The split into two levers is
the plausible reading of the mechanism, not a measured result: the top of a body
is read before the model knows it needs the second skill, so a pointer that
lives only there has plausibly been scrolled past by the time it matters. Taking
them apart would need two more runs — reword without moving, and move without
rewording — and nobody has paid for those. Until someone does, ship both.
⚠️ **Do not read a rise between two `RUNS=3` runs as an improvement.** One ⚠️ **Do not read a rise between two `RUNS=3` runs as an improvement.** One
sample is 33 points there, and the two weak-pointer runs above (33% / 33% and sample is 33 points there, and the two weak-pointer runs above (33% / 33% and
@ -452,7 +470,9 @@ break.
hand-off has a `chain` case — a pointer between skills is otherwise measured hand-off has a `chain` case — a pointer between skills is otherwise measured
by nothing at all, and a skill reached only that way scores a clean 100% on by nothing at all, and a skill reached only that way scores a clean 100% on
direct cases while the hand-off never fires. It does not count toward the direct cases while the hand-off never fires. It does not count toward the
floor in 4. floor in 4. **Then read the target's body as the caller arriving through that
pointer** — the case proves the load, never that what loads is the half that
caller needs (#2264).
6. `npm run verify:skills` passes and the listing is under budget. 6. `npm run verify:skills` passes and the listing is under budget.
7. `RUNS=5 npm run skills:eval` — the **whole** suite — is ≥80% on every 7. `RUNS=5 npm run skills:eval` — the **whole** suite — is ≥80% on every
first-move case, including the skills you did not touch, and the hand-off first-move case, including the skills you did not touch, and the hand-off

View file

@ -57,11 +57,14 @@ const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8);
// acceptable is a separate judgement rather than one inherited from a number // acceptable is a separate judgement rather than one inherited from a number
// tuned for the other measurement. 0.5 is the weakest claim worth asserting — // tuned for the other measurement. 0.5 is the weakest claim worth asserting —
// the pointer is taken more often than not — and it is a floor on useful // the pointer is taken more often than not — and it is a floor on useful
// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. A // reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. One
// well-shaped pointer clears 0.8 outright: the committed `testing` -> // well-shaped pointer has cleared 0.8 outright: the committed `testing` ->
// `test-servers` cases measured 33% (RUNS=3) when `testing` merely classified // `test-servers` cases measured 33% (RUNS=3) when `testing` merely classified
// which work belonged to `test-servers`, and 100%/100% (RUNS=5) once #2247 // which work belonged to `test-servers`, and 100%/100% (RUNS=5) once #2247
// reshaped that into an imperative step. (An intermediate build of that change // reshaped that into an imperative step. That is ONE pointer over ONE edit that
// moved wording and placement together, so read it as an existence proof that
// the ceiling is above 0.8 — not as a general property of "well-shaped"
// pointers, and not as grounds for raising this floor (Copilot, #2264). (An intermediate build of that change
// measured 100%/80%. That is NOT an example of clearing a 0.8 bar — this // measured 100%/80%. That is NOT an example of clearing a 0.8 bar — this
// threshold is compared strictly, so 80% would fail one — but it is worth // threshold is compared strictly, so 80% would fail one — but it is worth
// knowing as the residual noise still present at RUNS=5.) What 0.5 buys // knowing as the residual noise still present at RUNS=5.) What 0.5 buys