From 18bda28ce1bdcc108b0c9a852eb7739ee2b104d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 9 Sep 2026 01:21:15 -0400 Subject: [PATCH 001/120] feat(test-servers): a CIMD showcase fixture that serves its own client metadata document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In CIMD (SEP-991) the `client_id` *is* a URL the authorization server dereferences to learn the client's metadata. Nothing in this repo served such a document, so a fixture could advertise `client_id_metadata_document_supported` and still be unusable — exercising CIMD meant standing up a second host by hand. That is why #2242 shipped verified by its tests alone, and why the v2.6.0 release smoke recorded it as the one contribution with an observable UI surface that could not be reached. The composable server now hosts the document itself when `oauth.clientMetadata` is set, gated on `supportCIMD` — advertising a client the server would then refuse to honour is a worse fixture than serving nothing. The document's `client_id` is derived from the **request** rather than from a configured issuer, so it stays correct when the harness picks the port, as the integration test does. `supportDCR: false` in the fixture is load-bearing rather than incidental. With DCR available a CIMD failure silently succeeds via dynamic registration and the reproduction proves nothing — during development a misconfigured run connected happily and reported `Dynamic (DCR)` with a `test_client_…` id, which reads as success until you check the client id. With DCR off, CIMD is the only path that can complete, so reaching a connected state is itself the assertion; the test pins the absent `registration_endpoint` so it stays that way. Verified end to end against the fixture: Connection Info read `Client registration — Client ID Metadata (CIMD)` with the client id equal to the metadata URL. That drive needed a self-signed HTTPS listener, because the Inspector requires the CIMD metadata URL to be HTTPS with no loopback exemption — filed separately as #2305, since it is a validation gap rather than a fixture one. Closes #2306 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RsmR1iQstcrEzJFmgZXGLi Signed-off-by: cliffhall --- .../mcp/oauth-cimd-fixture.test.ts | 138 ++++++++++++++++++ docs/test-servers.md | 55 +++++++ test-servers/configs/oauth-cimd-http.json | 37 +++++ test-servers/src/composable-test-server.ts | 28 ++++ test-servers/src/load-config.ts | 12 ++ test-servers/src/test-server-oauth.ts | 32 ++++ 6 files changed, 302 insertions(+) create mode 100644 clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts create mode 100644 test-servers/configs/oauth-cimd-http.json diff --git a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts new file mode 100644 index 00000000..e5621830 --- /dev/null +++ b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + loadConfig, + resolveConfig, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture + * that makes #2242 reproducible by hand. + * + * #2242 (CIMD provenance surviving the SDK's issuer-binding write) shipped + * verified by its own end-to-end tests, because nothing in this repo served a + * **client metadata document**: in CIMD the `client_id` is a URL the + * authorization server dereferences, so exercising it meant standing up a + * second host. The v2.6.0 release ledger recorded that as the one row with an + * observable UI surface and no way to reach it. + * + * What this file protects is the fixture itself, not the fix. The manual + * reproduction depends on three things being true of the served document, and + * each of them is the kind of thing that breaks silently: + * + * - the AS advertises `client_id_metadata_document_supported`, or the + * Inspector's CIMD pre-registration bails out before storing anything; + * - it advertises **no** `registration_endpoint`, or a CIMD failure quietly + * falls back to DCR and the repro passes while proving nothing (this is + * exactly how the fixture first fooled its own author); + * - the document's `client_id` equals the URL it was fetched from, which is + * what makes it a legal CIMD client id rather than an arbitrary blob. + * + * The document is served over plain HTTP here. That is deliberate and is *not* + * a usable `clientMetadataUrl` for the Inspector, which requires HTTPS with no + * loopback exemption (#2305) — driving the UI needs a self-signed HTTPS + * listener, as `docs/test-servers.md` describes. This endpoint exists for the + * authorization-server side of the flow and for exactly these assertions. + */ +describe("CIMD showcase fixture (#2242)", () => { + let server: TestServerHttp | null = null; + + const configPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../../test-servers/configs/oauth-cimd-http.json", + ); + + afterEach(async () => { + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + /** + * Boot the showcase config on a harness-chosen port, so this cannot collide + * with a showcase server someone is running by hand. + */ + async function startShowcase(): Promise { + const resolved = resolveConfig(loadConfig(configPath)); + const started = createTestServerHttp({ + ...resolved, + serverInfo: createTestServerInfo("oauth-cimd-test", "1.0.0"), + port: undefined, + }); + await started.start(); + server = started; + return started; + } + + /** The MCP endpoint's origin, which is also the AS and the document host. */ + function originOf(started: TestServerHttp): string { + return new URL(started.url).origin; + } + + it("resolves the config with CIMD on and DCR deliberately off", () => { + const resolved = resolveConfig(loadConfig(configPath)); + expect(resolved.oauth?.supportCIMD).toBe(true); + // Not an oversight: with DCR available a CIMD failure silently succeeds + // via dynamic registration, and the repro stops proving anything. + expect(resolved.oauth?.supportDCR).toBe(false); + expect(resolved.oauth?.clientMetadata?.redirectUris).toContain( + "http://127.0.0.1:6276/oauth/callback", + ); + }); + + it("advertises CIMD support and no registration endpoint", async () => { + const started = await startShowcase(); + const res = await fetch( + `${originOf(started)}/.well-known/oauth-authorization-server`, + ); + expect(res.ok).toBe(true); + const metadata = await res.json(); + + expect(metadata.client_id_metadata_document_supported).toBe(true); + // The half that keeps the repro honest. + expect(metadata.registration_endpoint).toBeUndefined(); + }); + + it("serves a client metadata document whose client_id is its own URL", async () => { + const started = await startShowcase(); + const documentUrl = `${originOf(started)}/client-metadata.json`; + + const res = await fetch(documentUrl); + expect(res.ok).toBe(true); + const doc = await res.json(); + + // A CIMD client id IS the document's URL. Deriving it from the request + // rather than from a configured issuer is what keeps this true when the + // harness picks the port, as it does here. + expect(doc.client_id).toBe(documentUrl); + expect(doc.redirect_uris).toContain("http://127.0.0.1:6276/oauth/callback"); + // CIMD clients are public; the server's own CIMD branch issues no secret. + expect(doc.token_endpoint_auth_method).toBe("none"); + }); + + it("does not serve the document when CIMD is switched off", async () => { + const resolved = resolveConfig(loadConfig(configPath)); + const started = createTestServerHttp({ + ...resolved, + oauth: { ...resolved.oauth!, supportCIMD: false }, + serverInfo: createTestServerInfo("oauth-cimd-off-test", "1.0.0"), + port: undefined, + }); + await started.start(); + server = started; + + const res = await fetch(`${originOf(started)}/client-metadata.json`); + // Advertising a client this server would then refuse to honour is a worse + // fixture than serving nothing at all. + expect(res.status).toBe(404); + }); +}); diff --git a/docs/test-servers.md b/docs/test-servers.md index 4b568162..2b257590 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -55,6 +55,7 @@ as a missing capability rather than an error. | `oauth-revocation-http.json` / `oauth-no-revocation-http.json` **(legacy era)** | RFC 7009 token revocation on clear, with and without a `revocation_endpoint` | [#2144](https://github.com/modelcontextprotocol/inspector/issues/2144) | | `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) | | `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) | +| `oauth-cimd-http.json` **(legacy era)** | URL-based client IDs (CIMD / SEP-991), DCR deliberately off | [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) | | `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | | `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) | @@ -534,6 +535,60 @@ On the broken build you got a **"Re-authentication required"** banner with a **R Note that the fix here is presentational only. Making a `*.localhost` token endpoint actually **work** has to land in the SDK — the assertion runs inside `executeTokenRequest`, takes no options, and there is no hook the Inspector could reach. +## URL-based client IDs (CIMD / SEP-991) + +`oauth-cimd-http.json` is a combined AS + resource server that advertises +`client_id_metadata_document_supported: true` and — the part that makes it usable — **hosts the client +metadata document itself**, at `/client-metadata.json`. Plain streamable-HTTP; connect with the +**default (legacy)** protocol era. + +Hosting the document is the whole reason this fixture exists. In CIMD the `client_id` *is* a URL that +the authorization server dereferences to learn the client's metadata, so a server that merely +advertises support is only half a fixture: exercising it still meant standing up a second host by +hand. That is why [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) shipped +verified by its tests alone, and the v2.6.0 release ledger recorded it as the one row that had an +observable UI surface but no way to reach it. + +**`supportDCR` is `false` on purpose.** With both registration paths available a successful connection +proves nothing about which one ran — precisely the confusion #2242 was about, where Connection Info +reported `Dynamic (DCR)` for a connection that never issued a `POST /oauth/register`. With DCR off, +CIMD is the only way the flow can complete, so reaching a connected state *is* the assertion. + +⚠️ **CIMD is configured install-wide, not per server.** It lives in `client.json` +(`~/.mcp-inspector/storage/client.json`) as `cimd: { enabled: true, clientMetadataUrl }`, reachable +from **Client settings**, not from a server's own OAuth settings. A `clientMetadataUrl` written into a +catalog entry's `oauth` block is silently ignored — and with `supportDCR: true` the connection then +succeeds *via DCR*, which looks like CIMD working until you read the client id. + +⚠️ **The Inspector requires that URL to be HTTPS, and there is no loopback exemption** +(`getCimdClientMetadataUrlError` in `core/client/config-parse.ts`, applied to `client.json` on disk as +well as to the settings form). So this server's own `http://` document is **not** usable as a +`clientMetadataUrl`: it exists for the authorization-server side of the flow and for tests that drive +the AS directly. To drive the Inspector end to end you need the document served over HTTPS — +`https://127.0.0.1:8443/client-metadata.json` from a throwaway self-signed listener works, with +`NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* environment so its own fetch of that document +succeeds. That asymmetry is tracked in +[#2305](https://github.com/modelcontextprotocol/inspector/issues/2305); it is the same over-narrow +allow-list shape as the token-endpoint exemption above. + +With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. +It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the +metadata URL**, which is what CIMD means and what distinguishes it from a DCR-issued +`test_client_…`. On the broken build it read `Dynamic (DCR)` for exactly this flow +([#2242](https://github.com/modelcontextprotocol/inspector/issues/2242)). + +⚠️ **`clientMetadata.redirectUris` must list the callback for the port you are running.** The +Inspector's browser redirect is `/oauth/callback` and the CLI/TUI's is +`http://127.0.0.1:6276/oauth/callback`; the authorization server checks the incoming `redirect_uri` +against this list. The shipped fixture lists **6274** (the default), **6330** and **6276**; on any +other port the flow fails with `Invalid redirect_uri`, which reads like a CIMD problem and is not one. +Add your port to the config rather than debugging the registration path. + +⚠️ **The same fixed-`issuerUrl` hazard as the fixture above applies**, for the same reason: the +`client_id` this server publishes is derived from its issuer URL, so a server that walked to another +port on `EADDRINUSE` publishes a `client_id` pointing at whatever process holds 8092. Check with +`lsof -nP -iTCP:8092 -sTCP:LISTEN` before believing a failure. + ## Revoking tokens on clear (RFC 7009) `oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. diff --git a/test-servers/configs/oauth-cimd-http.json b/test-servers/configs/oauth-cimd-http.json new file mode 100644 index 00000000..01c6afcc --- /dev/null +++ b/test-servers/configs/oauth-cimd-http.json @@ -0,0 +1,37 @@ +{ + "serverInfo": { + "name": "oauth-cimd-showcase", + "version": "1.0.0" + }, + "tools": [ + { + "preset": "echo" + } + ], + "oauth": { + "enabled": true, + "mode": "combined", + "requireAuth": true, + "scopesSupported": [ + "mcp" + ], + "supportCIMD": true, + "supportDCR": false, + "supportRefreshTokens": true, + "clientMetadata": { + "clientName": "MCP Inspector (CIMD test fixture)", + "scope": "mcp", + "redirectUris": [ + "http://127.0.0.1:6274/oauth/callback", + "http://localhost:6274/oauth/callback", + "http://127.0.0.1:6330/oauth/callback", + "http://localhost:6330/oauth/callback", + "http://127.0.0.1:6276/oauth/callback" + ] + } + }, + "transport": { + "type": "streamable-http", + "port": 8092 + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 10e895d9..2bdf78e9 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -734,6 +734,34 @@ export interface ServerConfig { */ supportCIMD?: boolean; + /** + * Serve a CIMD client metadata document from this server, so a CIMD + * fixture is self-contained. + * + * CIMD makes the `client_id` a URL that the authorization server fetches + * to learn the client's metadata (SEP-991). Nothing in this repo served + * such a document, so exercising CIMD meant standing up a second host by + * hand — which is why #2242 shipped verified only by its tests. With this + * set, the server hosts the document at `clientMetadataPath` (default + * `/client-metadata.json`) and that URL is a usable `client_id`. + * + * `redirectUris` MUST list the Inspector's callback for the port you run + * it on (`/oauth/callback`) — the authorization server checks + * the incoming `redirect_uri` against this list, and a mismatch fails the + * flow with `Invalid redirect_uri` rather than anything CIMD-specific. + * + * Only served when `supportCIMD` is true: a document advertising a client + * the server would then refuse is a worse fixture than none. + */ + clientMetadata?: { + redirectUris: string[]; + clientName?: string; + scope?: string; + }; + + /** Where to serve `clientMetadata` (default `/client-metadata.json`). */ + clientMetadataPath?: string; + /** * Token expiration time in seconds (default: 3600) */ diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 3b32862f..2b4f2e83 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -37,6 +37,18 @@ export interface ConfigFileOAuth { }>; supportDCR?: boolean; supportCIMD?: boolean; + /** + * Serve a CIMD client metadata document, making a CIMD fixture + * self-contained. `redirectUris` must list the Inspector callback for the + * web port under test. See the field's doc comment in + * `composable-test-server.ts`. + */ + clientMetadata?: { + redirectUris: string[]; + clientName?: string; + scope?: string; + }; + clientMetadataPath?: string; tokenExpirationSeconds?: number; supportRefreshTokens?: boolean; /** RFC 7009 revocation endpoint; default true (#2144). */ diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index e73e0e7a..1ca7fa2d 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -283,6 +283,38 @@ function setupMetadataEndpoints( }); } + // CIMD client metadata document (SEP-991). The `client_id` in a CIMD flow is + // a URL the authorization server dereferences, so a fixture that advertises + // `client_id_metadata_document_supported` without hosting a document + // anywhere is only half a fixture — it needs a second host to be usable at + // all. Serving it here makes a CIMD run self-contained. + // + // Gated on `supportCIMD` as well as on the document's presence: advertising + // a client this server would then refuse to honour is worse than serving + // nothing. + if (config.supportCIMD && config.clientMetadata) { + const doc = config.clientMetadata; + const metadataPath = config.clientMetadataPath ?? "/client-metadata.json"; + app.get(metadataPath, (req: Request, res: Response) => { + // Derived from the request rather than from `issuerUrl`, so the + // document's own `client_id` always equals the URL it was fetched from + // — which is what CIMD requires, and what stays true if the server + // walked to another port on EADDRINUSE. + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + res.json({ + client_id: new URL(metadataPath, requestBaseUrl).href, + client_name: doc.clientName ?? "MCP Inspector (CIMD test fixture)", + redirect_uris: doc.redirectUris, + // CIMD clients are public and authenticate with nothing; the server's + // own CIMD branch assumes exactly this (no client_secret is issued). + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + ...(doc.scope ? { scope: doc.scope } : {}), + }); + }); + } + // OAuth Protected Resource Metadata. `resourceMetadataPath` moves the // document off the well-known path entirely (rather than serving both), so // a client that ignores the advertised `resource_metadata` URL gets a 404 From 21d16879105033e410c6ad84e561e0ef05ee06f1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 10 Sep 2026 22:39:42 -0400 Subject: [PATCH 002/120] chore: bump the vitest trio to 4.1.11 past GHSA-82fw-gwwq-j7x9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `vitest` and `@vitest/coverage-v8` (root) and `@vitest/browser-playwright` (web) from 4.1.10 to 4.1.11, clearing the moderate, dev-only path-traversal advisory against `@vitest/mocker`. The three pins alone do not resolve: `@vitest/browser-playwright` declares an exact peer on `vitest`, web does not declare `vitest` itself, and web's lockfile pins that peer-shadowed copy at 4.1.10 — a knot npm refuses rather than resolves. Regenerating web's lockfile clears it but re-resolves the whole tree, so this instead adds a `vitest` `overrides` entry to `clients/web/package.json`, the mechanism AGENTS.md already prescribes for a transitive that will not move. Churn stays inside the vitest constellation: ten `@vitest/*` + `vitest` entries to 4.1.11, plus their own transitives (`obug`, `tinyexec`, `chai`). `clients/{cli,tui,launcher}` lockfiles are untouched, and `npm audit` reports no advisory in any of the five installs. AGENTS.md records the fourth edit, since the three-pin rule as written is necessary but not sufficient. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BkS1cKEesxXwSckjBpJBt6 Signed-off-by: cliffhall --- AGENTS.md | 2 +- clients/web/package-lock.json | 132 +++++++++++++++++----------------- clients/web/package.json | 5 +- package-lock.json | 116 +++++++++++++++--------------- package.json | 4 +- 5 files changed, 130 insertions(+), 129 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a42c9669..4f09d1c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ The reasoning behind each of these, and what breaks when it is ignored, is the - **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. - **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale. - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way). - - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. + - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301). - **A root-declared package that `core/` imports at runtime must also be named in all three bundler `external` lists** (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. - **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`). - **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both. diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 17a1e846..9af37724 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -42,7 +42,7 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-playwright": "4.1.11", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", @@ -3747,15 +3747,15 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", - "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -3766,18 +3766,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", - "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.10", - "@vitest/mocker": "4.1.10", + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", "tinyrainbow": "^3.1.0" }, "funding": { @@ -3785,7 +3785,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "peerDependenciesMeta": { "playwright": { @@ -3862,13 +3862,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3899,9 +3899,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3912,14 +3912,14 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3927,15 +3927,15 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3944,9 +3944,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3954,13 +3954,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4686,9 +4686,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT", "peer": true @@ -7129,9 +7129,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -9185,20 +9185,20 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9226,12 +9226,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -9276,17 +9276,17 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -9306,9 +9306,9 @@ } }, "node_modules/vitest/node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "peer": true, diff --git a/clients/web/package.json b/clients/web/package.json index bca64c61..aa4df909 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -65,7 +65,7 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-playwright": "4.1.11", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", @@ -77,6 +77,7 @@ "yaml": "^2.9.0" }, "overrides": { - "esbuild": "^0.28.2" + "esbuild": "^0.28.2", + "vitest": "4.1.11" } } diff --git a/package-lock.json b/package-lock.json index 337f87b0..943a1f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "4.1.11", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", @@ -49,7 +49,7 @@ "semver": "^7.8.5", "typescript": "~5.9.3", "typescript-eslint": "^8.65.0", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "engines": { "node": ">=22.19.0" @@ -1625,14 +1625,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1646,8 +1646,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1656,16 +1656,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1674,13 +1674,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1701,9 +1701,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1714,13 +1714,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1728,14 +1728,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1744,9 +1744,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1754,13 +1754,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4038,9 +4038,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4942,9 +4942,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -5256,19 +5256,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5296,12 +5296,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 6a18ee07..e92ef833 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "4.1.11", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", @@ -126,6 +126,6 @@ "semver": "^7.8.5", "typescript": "~5.9.3", "typescript-eslint": "^8.65.0", - "vitest": "4.1.10" + "vitest": "4.1.11" } } From a8cbc506ab16824c59dd9c5b51de4b53221b504a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 10 Sep 2026 22:49:36 -0400 Subject: [PATCH 003/120] fix: address Copilot review round 1 on #2306 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate `oauth.clientMetadataPath` the way the other two metadata paths are validated — in `loadConfig` for the JSON route and again at server setup for a `ServerConfig` built in code. This path is not merely advertised: it becomes the served document's own `client_id`, so an off-origin or query-bearing value would publish a client id this server cannot honour. Derive that `client_id` from `req.originalUrl` rather than from the registered route, so a document fetched with a query string answers with a `client_id` byte-identical to the URL it was fetched from — the equality CIMD turns on. Correct the docs' fixed-port warning: this fixture configures no `issuerUrl` and derives the `client_id` from the request, which is exactly why the integration test can drive it on a harness-chosen port. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UWGH2ytPzSPxnp4cibaV3Z Signed-off-by: cliffhall --- .../mcp/oauth-cimd-fixture.test.ts | 56 +++++++++++++++++++ docs/test-servers.md | 9 +-- test-servers/src/composable-test-server.ts | 11 +++- test-servers/src/load-config.ts | 11 ++++ test-servers/src/test-server-oauth.ts | 31 +++++++++- 5 files changed, 111 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts index e5621830..759d1d2a 100644 --- a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts +++ b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -119,6 +121,60 @@ describe("CIMD showcase fixture (#2242)", () => { expect(doc.token_endpoint_auth_method).toBe("none"); }); + it("preserves a query-bearing document URL in the client_id it publishes", async () => { + const started = await startShowcase(); + const documentUrl = `${originOf(started)}/client-metadata.json?profile=a`; + + const res = await fetch(documentUrl); + expect(res.ok).toBe(true); + const doc = await res.json(); + + // CIMD turns on the document's `client_id` being the URL it was fetched + // from. Answering `?profile=a` with the bare route would publish a + // document that fails that equality for a client id the server just + // served (Copilot). + expect(doc.client_id).toBe(documentUrl); + }); + + it("rejects a clientMetadataPath that would publish a foreign client_id", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cimd-config-")); + const badPath = path.join(dir, "bad-cimd.json"); + const base = JSON.parse(fs.readFileSync(configPath, "utf8")); + fs.writeFileSync( + badPath, + JSON.stringify({ + ...base, + oauth: { ...base.oauth, clientMetadataPath: "//other-host/doc" }, + }), + ); + + try { + // The path is not merely advertised: it becomes the document's own + // `client_id`, so an off-origin value publishes a client id naming a + // host this server does not serve. + expect(() => loadConfig(badPath)).toThrow(/clientMetadataPath/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects the same path built programmatically rather than from JSON", async () => { + const resolved = resolveConfig(loadConfig(configPath)); + const started = createTestServerHttp({ + ...resolved, + oauth: { ...resolved.oauth!, clientMetadataPath: "/doc?version=1" }, + serverInfo: createTestServerInfo("oauth-cimd-badpath-test", "1.0.0"), + port: undefined, + }); + server = started; + + // `loadConfig` covers the JSON route only, so the server-setup check is + // what catches a `ServerConfig` assembled in code — the same split the + // two existing metadata paths have. + await expect(started.start()).rejects.toThrow(/clientMetadataPath/); + server = null; + }); + it("does not serve the document when CIMD is switched off", async () => { const resolved = resolveConfig(loadConfig(configPath)); const started = createTestServerHttp({ diff --git a/docs/test-servers.md b/docs/test-servers.md index 2b257590..55afa07a 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -584,10 +584,11 @@ against this list. The shipped fixture lists **6274** (the default), **6330** an other port the flow fails with `Invalid redirect_uri`, which reads like a CIMD problem and is not one. Add your port to the config rather than debugging the registration path. -⚠️ **The same fixed-`issuerUrl` hazard as the fixture above applies**, for the same reason: the -`client_id` this server publishes is derived from its issuer URL, so a server that walked to another -port on `EADDRINUSE` publishes a `client_id` pointing at whatever process holds 8092. Check with -`lsof -nP -iTCP:8092 -sTCP:LISTEN` before believing a failure. +This fixture has **no** fixed-`issuerUrl` hazard, unlike several of the ones above: it configures no +`issuerUrl`, and the document's `client_id` is derived from the request it was fetched over — query +string included — so a server that walked to another port on `EADDRINUSE` still publishes a +`client_id` equal to the URL you fetched, and the integration test drives it on a harness-chosen port +for exactly that reason. The fixed-port dependency that *does* bite is `redirect_uris`, above. ## Revoking tokens on clear (RFC 7009) diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 2bdf78e9..2c094fc9 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -759,7 +759,16 @@ export interface ServerConfig { scope?: string; }; - /** Where to serve `clientMetadata` (default `/client-metadata.json`). */ + /** + * Where to serve `clientMetadata` (default `/client-metadata.json`). + * + * Must be origin-relative with no query or fragment, and is validated as + * such — both by `loadConfig` and again at server setup for a config built + * in code. Unlike the other metadata paths this one is not merely + * advertised: it becomes the document's own `client_id`, so an off-origin + * or query-bearing value would publish a client id this server cannot + * honour (Copilot). + */ clientMetadataPath?: string; /** diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 2b4f2e83..865fdaab 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -48,6 +48,11 @@ export interface ConfigFileOAuth { clientName?: string; scope?: string; }; + /** + * Where to serve `clientMetadata` (default `/client-metadata.json`); + * validated as an origin-relative path, since it becomes the document's own + * `client_id`. See `composable-test-server.ts`. + */ clientMetadataPath?: string; tokenExpirationSeconds?: number; supportRefreshTokens?: boolean; @@ -260,6 +265,12 @@ function validateConfig( `Invalid config in ${filePath}: oauth.asMetadataPath must be an origin-relative path (e.g. "/.well-known/openid-configuration") — a value such as "//host/doc" would move the document off this server entirely`, ); } + const cimdPath = oauth.clientMetadataPath; + if (cimdPath !== undefined && !isOriginRelativePath(cimdPath)) { + throw new Error( + `Invalid config in ${filePath}: oauth.clientMetadataPath must be an origin-relative path (e.g. "/client-metadata.json") — this path becomes the document's own client_id, so a value such as "//host/doc" would publish a client id naming a host this server does not serve`, + ); + } if (transportType === "stdio" && oauth.enabled === true) { throw new Error( `Invalid config in ${filePath}: oauth requires streamable-http or sse transport`, diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 1ca7fa2d..19c716ca 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -101,6 +101,27 @@ function asMetadataPath(config: OAuthConfig): string { return path; } +/** + * Where the CIMD client metadata document is served from, validated the same + * way the two metadata paths above are — and for a sharper reason than either. + * This path is not merely advertised: it becomes the document's own + * `client_id`, so a value such as `//other-host/doc` would publish a client id + * naming a host this server does not control, and `/doc?version=1` would + * publish one that cannot reach the route Express registered (Copilot). + */ +function clientMetadataPath(config: OAuthConfig): string { + const path = config.clientMetadataPath; + if (path === undefined) { + return "/client-metadata.json"; + } + if (!isOriginRelativePath(path)) { + throw new Error( + `oauth.clientMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`, + ); + } + return path; +} + /** * The `WWW-Authenticate` challenge sent with every 401. * @@ -294,15 +315,21 @@ function setupMetadataEndpoints( // nothing. if (config.supportCIMD && config.clientMetadata) { const doc = config.clientMetadata; - const metadataPath = config.clientMetadataPath ?? "/client-metadata.json"; + const metadataPath = clientMetadataPath(config); app.get(metadataPath, (req: Request, res: Response) => { // Derived from the request rather than from `issuerUrl`, so the // document's own `client_id` always equals the URL it was fetched from // — which is what CIMD requires, and what stays true if the server // walked to another port on EADDRINUSE. + // + // `originalUrl` rather than the registered route, so a client id that + // carries a query string (`/client-metadata.json?profile=a`) still gets + // a document whose `client_id` is byte-identical to the URL that was + // fetched. Answering with the bare route instead would hand back a + // document that fails the very equality CIMD turns on (Copilot). const requestBaseUrl = `${req.protocol}://${req.get("host")}`; res.json({ - client_id: new URL(metadataPath, requestBaseUrl).href, + client_id: new URL(req.originalUrl, requestBaseUrl).href, client_name: doc.clientName ?? "MCP Inspector (CIMD test fixture)", redirect_uris: doc.redirectUris, // CIMD clients are public and authenticate with nothing; the server's From 8dd7bc2920f7833e3386f471773f7e65cfd86c12 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 00:45:04 -0400 Subject: [PATCH 004/120] feat: bound every OAuth-path request with a deadline Protected-resource-metadata discovery, authorization-server metadata discovery, dynamic client registration, the token exchange and the refresh all went out with no AbortSignal at all. Token revocation was the single exception, and its withDeadline race is the pattern this generalizes. Adds core/auth/requestTimeout.ts: withOAuthRequestTimeout wraps a fetch so every call through it is bounded, composing any caller signal with AbortSignal.any and racing a timer so the deadline holds even where the signal is dropped in flight (the browser's remote fetch re-issues the call to /api/fetch and forwards no signal). The rejection is an OAuthRequestTimeoutError naming the URL, so a stall says which endpoint stalled rather than riding the SDK handshake timeout and reporting "Request timed out". Applied inside InspectorClient.buildEffectiveAuthFetch rather than to this.fetchFn, because the transport is handed this.fetchFn directly and a deadline on a Streamable HTTP / SSE response would sever the stream it is supposed to hold open. Also applied to the CLI's storedAuthFetch, which calls SDK discovery directly and so has nothing else bounding it. Closes #2319 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- clients/cli/src/cli.ts | 10 +- .../src/test/core/auth/requestTimeout.test.ts | 186 ++++++++++++++++++ core/auth/index.ts | 7 + core/auth/requestTimeout.ts | 150 ++++++++++++++ core/mcp/inspectorClient.ts | 11 +- 5 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/test/core/auth/requestTimeout.test.ts create mode 100644 core/auth/requestTimeout.ts diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 28ccd0dc..990267d0 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -53,6 +53,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 +354,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) => diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts new file mode 100644 index 00000000..05c92029 --- /dev/null +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + OAuthRequestTimeoutError, + withOAuthRequestTimeout, +} from "@inspector/core/auth/requestTimeout.js"; + +const URL_UNDER_TEST = + "https://as.example.com/.well-known/oauth-authorization-server"; + +/** A fetch that never settles — the wedged authorization server of #2319. */ +const neverSettles: typeof fetch = () => new Promise(() => {}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("withOAuthRequestTimeout", () => { + it("passes a prompt response straight through", async () => { + const ok = new Response("{}", { status: 200 }); + const inner = vi.fn().mockResolvedValue(ok); + const wrapped = withOAuthRequestTimeout(inner, 1000); + + await expect(wrapped(URL_UNDER_TEST)).resolves.toBe(ok); + expect(inner).toHaveBeenCalledTimes(1); + }); + + it("rejects a stalled request with an error naming the endpoint", async () => { + vi.useFakeTimers(); + const wrapped = withOAuthRequestTimeout(neverSettles, 1000); + + const pending = wrapped(URL_UNDER_TEST); + const assertion = expect(pending).rejects.toThrow( + `OAuth request to ${URL_UNDER_TEST} timed out after 1000ms`, + ); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + }); + + it("reports the timeout as OAuthRequestTimeoutError carrying url and budget", async () => { + vi.useFakeTimers(); + const wrapped = withOAuthRequestTimeout(neverSettles, 1000); + + const pending = wrapped(new URL(URL_UNDER_TEST)); + const assertion = pending.catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + const err = await assertion; + + expect(err).toBeInstanceOf(OAuthRequestTimeoutError); + const timeout = err as OAuthRequestTimeoutError; + expect(timeout.name).toBe("OAuthRequestTimeoutError"); + expect(timeout.url).toBe(URL_UNDER_TEST); + expect(timeout.timeoutMs).toBe(1000); + }); + + it("takes the URL off a Request object too", async () => { + vi.useFakeTimers(); + const wrapped = withOAuthRequestTimeout(neverSettles, 1000); + + const pending = wrapped(new Request(URL_UNDER_TEST, { method: "POST" })); + const assertion = pending.catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + url: URL_UNDER_TEST, + }); + }); + + it("aborts the underlying fetch rather than leaving it running detached", async () => { + vi.useFakeTimers(); + let seen: AbortSignal | undefined; + const inner: typeof fetch = (_input, init) => { + seen = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new Error("aborted by signal")), + ); + }); + }; + const wrapped = withOAuthRequestTimeout(inner, 1000); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect(seen?.aborted).toBe(true); + // The underlying rejection can win the race against our own; either way the + // caller must be told it was a timeout, and which endpoint stalled. + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + }); + + it("still enforces the deadline when the signal is dropped in flight", async () => { + vi.useFakeTimers(); + // The browser's `createRemoteFetch` re-issues the call to `/api/fetch` and + // forwards no signal, so aborting locally cancels nothing. The race is what + // has to bound it. + const signalIgnoring: typeof fetch = () => new Promise(() => {}); + const wrapped = withOAuthRequestTimeout(signalIgnoring, 1000); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + }); + + it("preserves a caller-supplied signal, which still wins on its own", async () => { + const inner: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new Error("caller cancelled")), + ); + }); + const wrapped = withOAuthRequestTimeout(inner, 60_000); + + const caller = new AbortController(); + const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal }); + caller.abort(); + + await expect(pending).rejects.toThrow("caller cancelled"); + }); + + it("propagates a non-timeout failure unchanged", async () => { + const boom = new TypeError("network error"); + const inner = vi.fn().mockRejectedValue(boom); + const wrapped = withOAuthRequestTimeout(inner, 1000); + + await expect(wrapped(URL_UNDER_TEST)).rejects.toBe(boom); + }); + + it("rounds a fractional budget, because setTimeout takes an integer", async () => { + vi.useFakeTimers(); + // A budget derived from `performance.now()` arrives fractional; a fractional + // delay throws ERR_OUT_OF_RANGE before the request is ever made. + const wrapped = withOAuthRequestTimeout(neverSettles, 1000.4); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + timeoutMs: 1000, + }); + }); + + it("clamps a negative budget to zero rather than throwing", async () => { + vi.useFakeTimers(); + const wrapped = withOAuthRequestTimeout(neverSettles, -1); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(0); + + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + timeoutMs: 0, + }); + }); + + it("defaults to a generous budget a slow authorization server can meet", async () => { + vi.useFakeTimers(); + const wrapped = withOAuthRequestTimeout(neverSettles); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1); + expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe( + "pending", + ); + + await vi.advanceTimersByTimeAsync(1); + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + }); + }); + + it("clears its timer once the request settles", async () => { + vi.useFakeTimers(); + const clearSpy = vi.spyOn(globalThis, "clearTimeout"); + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue(new Response(null)), + 1000, + ); + + await wrapped(URL_UNDER_TEST); + + expect(clearSpy).toHaveBeenCalled(); + // Nothing is left to fire, so no unhandled rejection can surface later. + await vi.advanceTimersByTimeAsync(5000); + }); +}); diff --git a/core/auth/index.ts b/core/auth/index.ts index c74702ce..6dd49b3b 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -51,6 +51,13 @@ export { withRfc8414OidcCompat, } from "./oidcDiscoveryCompat.js"; +// OAuth-path request deadline (#2319) +export { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + OAuthRequestTimeoutError, + withOAuthRequestTimeout, +} from "./requestTimeout.js"; + // Storage export type { OAuthStorage, diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts new file mode 100644 index 00000000..e89aa261 --- /dev/null +++ b/core/auth/requestTimeout.ts @@ -0,0 +1,150 @@ +/** + * A bound on every network call the Inspector makes on the OAuth path (#2319). + * + * ## Why this exists + * + * Protected-resource-metadata discovery, authorization-server metadata + * discovery, dynamic client registration, the token exchange and the refresh + * all went out with no `AbortSignal` at all. Token revocation was the single + * exception — `revocation.ts` has carried its own deadline since #2144, and it + * is the pattern this module generalizes. + * + * An unbounded fetch matters more here than it usually would, because of where + * this work runs and what the UI does while it runs: + * + * - It runs **server-side**, in the Inspector's own Node process, proxied + * through the backend. A stall is therefore invisible in browser devtools — + * #2188's reporter correctly observed that no request left the browser at + * all while a Node-side socket sat established and idle. + * - Connect-time auth errors deliberately hold the connection status at + * `"connecting"` rather than moving it to `"error"` + * (`isConnectAuthRecoveryError`, `challenge.ts`), on the theory that a + * redirect is about to end the attempt. A stalled fetch inside that window is + * a silent, unbounded spinner rather than a surfaced failure. + * + * Today the common case is bounded, but only incidentally: a discovery stall + * that happens to sit inside an SDK `initialize` rides that request's own + * timeout and surfaces after 60s as `Request timed out`. This module makes the + * bound deliberate, applies it to the stalls that sit *outside* an SDK request + * too, and — the point of naming the URL in the error — says *which* endpoint + * stalled instead of blaming the handshake. + * + * ## Why a race, and not just the signal + * + * The signal alone cannot enforce this. In the browser the OAuth fetch is + * `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch` and + * does not forward `init.signal`; the backend's outbound fetch gets no signal + * either. So on exactly the path this bound exists for — a browser session + * against a wedged authorization server — aborting the local promise would + * change nothing. The race is what actually enforces the deadline; the signal + * is kept because it does cancel the direct-fetch paths (CLI, TUI, backend) + * rather than merely abandoning them. Same reasoning, same shape, as + * `revokeToken`. + */ + +/** + * 30 seconds. Deliberately generous: discovery against a slow or cold-starting + * authorization server is legitimate, and a bound that fires on a server that + * was going to answer is worse than the unbounded wait it replaced. It still + * halves the 60s the SDK handshake incidentally provides, and it is the only + * bound at all on the legs that sit outside an SDK request. + */ +export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; + +/** + * Raised when an OAuth-path request outlives its budget. + * + * Carries the URL because "discovery timed out" without one is only marginally + * better than a spinner: the OAuth path makes several requests to several + * hosts, and which of them stalled is the whole diagnostic. + */ +export class OAuthRequestTimeoutError extends Error { + readonly url: string; + readonly timeoutMs: number; + + constructor(url: string, timeoutMs: number) { + super(`OAuth request to ${url} timed out after ${timeoutMs}ms`); + this.name = "OAuthRequestTimeoutError"; + this.url = url; + this.timeoutMs = timeoutMs; + } +} + +/** The request URL, whatever form `fetch`'s first argument took. */ +function requestUrlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** + * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. + * + * Apply this to an **OAuth-path** fetch only. It must never wrap the transport + * fetch: a Streamable HTTP or SSE response is a long-lived stream that is + * *supposed* to stay open, and a deadline there would sever every connection + * after the budget. `InspectorClient` keeps the two apart by wrapping inside + * `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the + * transport is handed directly. + * + * A caller-supplied `init.signal` is preserved — the two are composed with + * `AbortSignal.any`, so an outer cancellation still wins and this wrapper only + * ever *adds* a reason to give up. + */ +export function withOAuthRequestTimeout( + fetchFn: typeof fetch, + timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, +): typeof fetch { + // Whole milliseconds, because `setTimeout` and `AbortSignal.timeout` both + // take an integer: Node throws `ERR_OUT_OF_RANGE` on a fractional delay — + // before the fetch, so the request is never sent and the caller sees a failed + // operation rather than a timeout. A budget derived from `performance.now()` + // is fractional, so this is reachable. `revocation.ts:253` carries the same + // note for the same reason. + const budget = Math.max(0, Math.round(timeoutMs)); + + return async (input, init) => { + const url = requestUrlOf(input); + const controller = new AbortController(); + // `init.signal` is `AbortSignal | null | undefined`; only an actual signal + // is worth composing. + const callerSignal = init?.signal ?? undefined; + const signal = callerSignal + ? AbortSignal.any([controller.signal, callerSignal]) + : controller.signal; + + let timer: ReturnType | undefined; + let timedOut = false; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + // Abort first so a direct fetch is actually cancelled rather than left + // running detached, then reject so the proxied fetch — which never saw + // the signal — is abandoned on schedule too. + controller.abort(new OAuthRequestTimeoutError(url, budget)); + reject(new OAuthRequestTimeoutError(url, budget)); + }, budget); + }); + + try { + return await Promise.race([ + fetchFn(input, { ...init, signal }), + deadline, + ]); + } catch (err) { + // `controller.abort()` above can reject the underlying fetch *before* + // `reject` runs, in which case the race settles with undici's + // `AbortError` instead of ours. Which of the two wins is an ordering + // detail of the fetch implementation, so normalize on the flag rather + // than on the error that surfaced: a timeout must always be reported as + // one, naming the endpoint. + if (timedOut) throw new OAuthRequestTimeoutError(url, budget); + throw err; + } finally { + /* v8 ignore next -- the Promise executor runs synchronously, so `timer` + is always assigned by the time this runs; the guard exists only because + TypeScript cannot see that. */ + if (timer !== undefined) clearTimeout(timer); + } + }; +} diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cec72b49..180243ad 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -256,6 +256,7 @@ import { } from "../auth/challenge.js"; import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js"; +import { withOAuthRequestTimeout } from "../auth/requestTimeout.js"; import type { TokenRevocationOutcome } from "../auth/revocation.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; @@ -991,7 +992,15 @@ export class InspectorClient extends InspectorClientEventTarget { } private buildEffectiveAuthFetch(): typeof fetch { - const base = this.fetchFn ?? fetch; + // #2319: bound every OAuth-path request — discovery, dynamic client + // registration, the token exchange, the refresh. Applied here rather than + // to `this.fetchFn` because the transport is handed `this.fetchFn` + // directly, and a deadline on a Streamable HTTP / SSE response would sever + // the very stream it is supposed to hold open. The cost of that placement + // is that the discovery the SDK runs from *inside* the transport (the + // 401/refresh path) is not covered here; that leg sits inside an SDK + // request and is bounded by its per-request timeout. + const base = withOAuthRequestTimeout(this.fetchFn ?? fetch); // Capture auth response bodies (OAuth discovery, DCR, token exchange) so // they're inspectable in the Network tab. Token-exchange responses carry // `access_token` / `refresh_token`; the Network UI masks those (and other From e89ea4cf19bb15d1ca71ac6f4dfab8ebd7ed5392 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:24:30 -0400 Subject: [PATCH 005/120] fix: address Copilot review round 1 on #2319 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all real: - The deadline stopped at the response headers. `fetch` resolves once headers arrive, so a server that sent headers — or half a JSON document — and then stalled left the caller's `response.json()` hanging with nothing watching it. The body is now buffered under the same race and the response rebuilt around it, preserving `url`, `redirected` and `type`, which have no constructor option, and passing `null` for a null-body status. - The caller's signal was only forwarded, not raced. On the browser's `createRemoteFetch` path the signal is dropped in flight, so an abort left the promise pending for the whole budget rather than the outer cancellation winning as documented. It is raced now, with the caller's own `reason` preserved, and the listener removed in `finally` since one caller signal outlives several requests. - A `Request` carries its signal on the input, not in `init`, so always passing our own `signal` in `init` silently overrode it. The caller signal is now derived from the `Request` when `init` has no `signal` key — and an explicit `null` there still wins, per the fetch spec. Eight new tests cover each: the stalled body, caller cancellation against a signal-ignoring fetch, an already-aborted signal, the Request-embedded signal, the explicit-null override, listener removal, the rebuilt response's metadata and a null-body status. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 151 +++++++++++++++++- core/auth/requestTimeout.ts | 125 +++++++++++++-- 2 files changed, 260 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 05c92029..5100083b 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -17,12 +17,18 @@ afterEach(() => { }); describe("withOAuthRequestTimeout", () => { - it("passes a prompt response straight through", async () => { - const ok = new Response("{}", { status: 200 }); - const inner = vi.fn().mockResolvedValue(ok); + it("passes a prompt response through", async () => { + // Not the same object: the body is buffered under the deadline (see the + // stalled-body case below) and the response rebuilt around it. + const inner = vi + .fn() + .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); const wrapped = withOAuthRequestTimeout(inner, 1000); - await expect(wrapped(URL_UNDER_TEST)).resolves.toBe(ok); + const response = await wrapped(URL_UNDER_TEST); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); expect(inner).toHaveBeenCalledTimes(1); }); @@ -103,7 +109,7 @@ describe("withOAuthRequestTimeout", () => { expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); }); - it("preserves a caller-supplied signal, which still wins on its own", async () => { + it("forwards a caller-supplied signal to the inner fetch", async () => { const inner: typeof fetch = (_input, init) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => @@ -119,6 +125,139 @@ describe("withOAuthRequestTimeout", () => { await expect(pending).rejects.toThrow("caller cancelled"); }); + it("races caller cancellation too, so it wins on a signal-ignoring fetch", async () => { + // Forwarding alone is not enough on the browser's `createRemoteFetch` path, + // which drops the signal: without the race the caller's abort would sit + // pending for the whole budget instead of winning. + const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); + + const caller = new AbortController(); + const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal }); + const reason = new Error("caller gave up"); + caller.abort(reason); + + await expect(pending).rejects.toBe(reason); + }); + + it("rejects immediately when the caller's signal is already aborted", async () => { + const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); + const reason = new Error("already gone"); + + await expect( + wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason); + }); + + it("honours the signal embedded in a Request input", async () => { + // `init.signal` is absent here, so the Request's own signal is the caller's + // — reading `init` alone would silently override it. + const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); + + const caller = new AbortController(); + const pending = wrapped( + new Request(URL_UNDER_TEST, { signal: caller.signal }), + ); + const reason = new Error("request cancelled"); + caller.abort(reason); + + await expect(pending).rejects.toBe(reason); + }); + + it("lets an explicit null init.signal override a Request's own", async () => { + vi.useFakeTimers(); + // Per the fetch spec a present `signal` key wins, `null` included. + const wrapped = withOAuthRequestTimeout(neverSettles, 1000); + + const caller = new AbortController(); + const pending = wrapped( + new Request(URL_UNDER_TEST, { signal: caller.signal }), + { signal: null }, + ); + const assertion = pending.catch((err: unknown) => err); + caller.abort(new Error("ignored")); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + }); + + it("removes its abort listener once the request settles", async () => { + const caller = new AbortController(); + const removeSpy = vi.spyOn(caller.signal, "removeEventListener"); + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue(new Response("{}")), + 60_000, + ); + + await wrapped(URL_UNDER_TEST, { signal: caller.signal }); + + // A caller signal outlives one request — one connect attempt makes several + // — so a listener left behind per request would accumulate on it. + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("bounds a stalled response body, not just the headers", async () => { + vi.useFakeTimers(); + // `fetch` resolves on headers. A server that sends them and then stalls + // would leave the caller's `response.json()` hanging unwatched. + const stalledBody: typeof fetch = () => + Promise.resolve( + new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode('{"issuer":')); + }, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + const wrapped = withOAuthRequestTimeout(stalledBody, 1000); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + }); + + it("returns a readable response whose metadata survives the rebuild", async () => { + const original = new Response('{"issuer":"https://as.example.com"}', { + status: 201, + statusText: "Created", + headers: { "content-type": "application/json", "x-probe": "kept" }, + }); + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue(original), + 1000, + ); + + const response = await wrapped(URL_UNDER_TEST); + + expect(response.status).toBe(201); + expect(response.statusText).toBe("Created"); + expect(response.ok).toBe(true); + expect(response.headers.get("x-probe")).toBe("kept"); + expect(response.url).toBe(original.url); + expect(response.redirected).toBe(original.redirected); + expect(response.type).toBe(original.type); + await expect(response.json()).resolves.toEqual({ + issuer: "https://as.example.com", + }); + }); + + it("rebuilds a null-body status without handing it a body", async () => { + // `new Response(body, { status: 204 })` throws; the rebuild has to pass null. + const wrapped = withOAuthRequestTimeout( + vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })), + 1000, + ); + + const response = await wrapped(URL_UNDER_TEST); + + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + }); + it("propagates a non-timeout failure unchanged", async () => { const boom = new TypeError("network error"); const inner = vi.fn().mockRejectedValue(boom); @@ -173,7 +312,7 @@ describe("withOAuthRequestTimeout", () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(globalThis, "clearTimeout"); const wrapped = withOAuthRequestTimeout( - vi.fn().mockResolvedValue(new Response(null)), + vi.fn().mockResolvedValue(new Response("{}")), 1000, ); diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index e89aa261..8fae3dcb 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -22,6 +22,13 @@ * redirect is about to end the attempt. A stalled fetch inside that window is * a silent, unbounded spinner rather than a surfaced failure. * + * The deadline covers the **whole exchange**, not just the headers: `fetch` + * resolves as soon as response headers arrive, so a server that sends headers + * — or half a JSON document — and then stalls would leave the caller's + * `response.json()` hanging with nothing watching it. Every response on this + * path is a small finite document, so the body is buffered under the same race + * and the response rebuilt around it. + * * Today the common case is bounded, but only incidentally: a discovery stall * that happens to sit inside an SDK `initialize` rides that request's own * timeout and surfaces after 60s as `Request timed out`. This module makes the @@ -77,6 +84,63 @@ function requestUrlOf(input: RequestInfo | URL): string { return input.url; } +/** + * The caller's own `AbortSignal`, if it supplied one. + * + * A `Request` carries its signal on the *input*, not in `init`, and this + * wrapper always passes a `signal` of its own in `init` — so reading `init` + * alone would silently override the embedded one and change ordinary `fetch` + * cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))` + * (Copilot). + * + * `init.signal` still wins whenever the key is *present*, per the fetch spec: + * an explicit `null` there means "no signal" even when `input` is a `Request` + * that has one. + */ +function callerSignalOf( + input: RequestInfo | URL, + init: RequestInit | undefined, +): AbortSignal | undefined { + if (init && "signal" in init) return init.signal ?? undefined; + if (typeof input !== "string" && !(input instanceof URL)) return input.signal; + return undefined; +} + +/** + * Statuses the Fetch Standard defines as null-body. Handing the `Response` + * constructor a body with one of these throws a `TypeError`, so the rebuilt + * response below must pass `null` instead. + */ +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +/** + * Rebuild a `Response` around an already-buffered body. + * + * `url`, `redirected` and `type` are getters with no constructor option, and + * callers do read them — the RFC 8414/OIDC compat wrapper stamps a header + * naming the URL a body came from, and the SDK's discovery reports the + * responding URL in its errors. So they are copied across explicitly rather + * than silently reset to `""` / `false` / `"default"`. + */ +function rebuildResponse(response: Response, body: ArrayBuffer): Response { + const rebuilt = new Response( + NULL_BODY_STATUSES.has(response.status) ? null : body, + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }, + ); + for (const key of ["url", "redirected", "type"] as const) { + Object.defineProperty(rebuilt, key, { + value: response[key], + enumerable: true, + configurable: true, + }); + } + return rebuilt; +} + /** * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. * @@ -87,9 +151,14 @@ function requestUrlOf(input: RequestInfo | URL): string { * `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the * transport is handed directly. * - * A caller-supplied `init.signal` is preserved — the two are composed with - * `AbortSignal.any`, so an outer cancellation still wins and this wrapper only - * ever *adds* a reason to give up. + * A caller's own signal is preserved — whether it arrived in `init` or embedded + * in a `Request` — and is both forwarded to the inner fetch (composed with + * `AbortSignal.any`) and raced here, so an outer cancellation still wins even on + * a fetch that drops the signal. This wrapper only ever *adds* a reason to give + * up; it never removes the caller's. + * + * The response **body** is buffered under the same deadline, because `fetch` + * resolves on headers and a stalled body would otherwise be unbounded. */ export function withOAuthRequestTimeout( fetchFn: typeof fetch, @@ -106,16 +175,18 @@ export function withOAuthRequestTimeout( return async (input, init) => { const url = requestUrlOf(input); const controller = new AbortController(); - // `init.signal` is `AbortSignal | null | undefined`; only an actual signal - // is worth composing. - const callerSignal = init?.signal ?? undefined; + const callerSignal = callerSignalOf(input, init); const signal = callerSignal ? AbortSignal.any([controller.signal, callerSignal]) : controller.signal; let timer: ReturnType | undefined; let timedOut = false; - const deadline = new Promise((_resolve, reject) => { + let onCallerAbort: (() => void) | undefined; + + // The losing side of every race below: it rejects when the budget runs out, + // and when the caller cancels. + const abandoned = new Promise((_resolve, reject) => { timer = setTimeout(() => { timedOut = true; // Abort first so a direct fetch is actually cancelled rather than left @@ -124,20 +195,48 @@ export function withOAuthRequestTimeout( controller.abort(new OAuthRequestTimeoutError(url, budget)); reject(new OAuthRequestTimeoutError(url, budget)); }, budget); + + // The caller's cancellation is raced too, not merely forwarded. Forwarding + // it is enough on a direct fetch, but on the browser's `createRemoteFetch` + // path — the one this wrapper exists for — the signal is dropped in + // flight, so an abort would otherwise leave this promise pending for the + // whole budget instead of the outer cancellation winning as documented + // (Copilot). The caller's own `reason` is preserved, so it still sees its + // abort rather than a substituted error. + if (callerSignal) { + if (callerSignal.aborted) { + reject(callerSignal.reason); + } else { + onCallerAbort = () => reject(callerSignal.reason); + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); + } + } }); try { - return await Promise.race([ + const response = await Promise.race([ fetchFn(input, { ...init, signal }), - deadline, + abandoned, ]); + // `fetch` resolves once the response *headers* arrive, so stopping here + // would leave the body unbounded: an authorization server can send + // headers, or half a JSON document, and then stall, and the caller's + // `response.json()` would hang with nothing watching it (Copilot). Every + // response on this path is a small, finite document — metadata, + // a registration, a token — so buffering it under the same deadline + // bounds the whole exchange. This is the other reason the wrapper must + // never be applied to the transport fetch, whose bodies are streams that + // are supposed to stay open. + const body = await Promise.race([response.arrayBuffer(), abandoned]); + return rebuildResponse(response, body); } catch (err) { // `controller.abort()` above can reject the underlying fetch *before* // `reject` runs, in which case the race settles with undici's // `AbortError` instead of ours. Which of the two wins is an ordering // detail of the fetch implementation, so normalize on the flag rather // than on the error that surfaced: a timeout must always be reported as - // one, naming the endpoint. + // one, naming the endpoint. A caller-driven abort leaves the flag false + // and so passes through with its own reason intact. if (timedOut) throw new OAuthRequestTimeoutError(url, budget); throw err; } finally { @@ -145,6 +244,12 @@ export function withOAuthRequestTimeout( is always assigned by the time this runs; the guard exists only because TypeScript cannot see that. */ if (timer !== undefined) clearTimeout(timer); + // Drop the listener even though it is `once`: a caller signal can outlive + // this request (one `AbortController` per connect attempt, several + // requests through it), and a listener per request would accumulate on it. + if (onCallerAbort && callerSignal) { + callerSignal.removeEventListener("abort", onCallerAbort); + } } }; } From a1cad93ed9b4c5ac7e017166306a4912da9395e2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:31:47 -0400 Subject: [PATCH 006/120] fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getCimdClientMetadataUrlError` rejected every non-HTTPS CIMD client metadata URL with no loopback exemption, and the same check runs against `client.json` on disk through `refineCimdMetadataUrl`, so it could not be worked around by hand-editing the file either. Every test server in this repo speaks plain HTTP, so CIMD could not be driven against any local fixture: #2242 shipped verified by tests alone, and reproducing it by hand meant standing up a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0` in the test server's environment. Accept plain `http:` on the three literals the SDK itself exempts from its token-endpoint TLS assertion — `localhost`, `127.0.0.1` and `[::1]`. That is option 1 in #2305 and matches what the runtime already tolerated: `core/auth/cimd.ts` notes that an already-stored `client_id` may be an `http://` URL used by local dev/test metadata servers. Anything outside those three, `localhost.` and `tenant.app.localhost` included, is still rejected — matching the SDK literal for literal keeps the two allow-lists from disagreeing about one URL. The exemption is a literal set rather than `isLoopbackHost` because `core/client/config-parse.ts` is browser-safe and `core/node/hostUrl.ts` imports `node:net`. The field error now names the exemption instead of saying only "must use HTTPS", and the form's hint and description say the same, so a developer pointing the field at a local fixture is told which spellings work. Closes #2305 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ClientSettingsForm/ClientSettingsForm.tsx | 7 ++- .../web/src/test/core/client/config.test.ts | 57 +++++++++++++++++++ .../mcp/oauth-cimd-fixture.test.ts | 32 +++++++++-- core/client/config-parse.ts | 45 ++++++++++++++- docs/test-servers.md | 25 +++++--- 5 files changed, 146 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx index 8014aa08..4a440c8a 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx @@ -307,15 +307,16 @@ export function ClientSettingsForm({ {settings.cimdEnabled && ( <> - The metadata document must be served over HTTPS and list - this redirect URI:{" "} + The metadata document must be served over HTTPS — or over + plain HTTP from localhost, 127.0.0.1 or [::1], for a local + fixture — and list this redirect URI:{" "} {typeof window !== "undefined" ? `${window.location.origin}/oauth/callback` : "http://localhost:6274/oauth/callback"} patch({ clientMetadataUrl: e.currentTarget.value }) diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts index bb2b7712..3b6e2663 100644 --- a/clients/web/src/test/core/client/config.test.ts +++ b/clients/web/src/test/core/client/config.test.ts @@ -206,6 +206,19 @@ describe("client config", () => { ).toThrow(/HTTPS/); }); + it("parseClientConfig accepts a loopback http CIMD URL", () => { + for (const clientMetadataUrl of [ + "http://localhost:8090/client-metadata.json", + "http://127.0.0.1:8090/client-metadata.json", + "http://[::1]:8090/client-metadata.json", + ]) { + const config = parseClientConfig({ + cimd: { enabled: true, clientMetadataUrl }, + }); + expect(config.cimd?.clientMetadataUrl).toBe(clientMetadataUrl); + } + }); + it("parseClientConfig rejects CIMD URL without path", () => { expect(() => parseClientConfig({ @@ -299,6 +312,50 @@ describe("client config", () => { CIMD_METADATA_URL_PATH_ERROR, ); }); + + it("accepts plain http on the three exempt loopback literals", () => { + expect( + getCimdClientMetadataUrlError( + "http://localhost:8090/client-metadata.json", + ), + ).toBeUndefined(); + expect( + getCimdClientMetadataUrlError( + "http://127.0.0.1:8090/client-metadata.json", + ), + ).toBeUndefined(); + expect( + getCimdClientMetadataUrlError("http://[::1]:8090/client-metadata.json"), + ).toBeUndefined(); + }); + + it("still requires a path on an exempt loopback host", () => { + expect(getCimdClientMetadataUrlError("http://localhost:8090")).toBe( + CIMD_METADATA_URL_PATH_ERROR, + ); + }); + + it("does not widen the exemption beyond the three literals", () => { + // `localhost.` and `*.localhost` resolve to loopback on every resolver, + // but they are outside the SDK's allow-list — so they are outside ours + // too, deliberately, rather than the two disagreeing about one URL. + for (const value of [ + "http://tenant.app.localhost:3300/client-metadata.json", + "http://127.0.0.2:8090/client-metadata.json", + "http://localhost.example.com/client-metadata.json", + ]) { + expect(getCimdClientMetadataUrlError(value)).toBe( + CIMD_METADATA_URL_HTTPS_ERROR, + ); + } + // The root-anchored spelling never reaches the protocol check at all: + // `isAbsoluteHttpUrl` rejects its trailing empty label first, so it is + // flagged as unparseable rather than as non-HTTPS. Pinned so a later + // change to either check has to decide about this case on purpose. + expect( + getCimdClientMetadataUrlError("http://localhost./client-metadata.json"), + ).toBe(CIMD_METADATA_URL_INVALID_ERROR); + }); }); it("formatClientConfigLoadError summarizes Zod validation failures", () => { diff --git a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts index 759d1d2a..96f30192 100644 --- a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts +++ b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts @@ -10,6 +10,10 @@ import { loadConfig, resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; +import { + getCimdClientMetadataUrlError, + parseClientConfig, +} from "@inspector/core/client/config-parse.js"; /** * Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture @@ -34,11 +38,13 @@ import { * - the document's `client_id` equals the URL it was fetched from, which is * what makes it a legal CIMD client id rather than an arbitrary blob. * - * The document is served over plain HTTP here. That is deliberate and is *not* - * a usable `clientMetadataUrl` for the Inspector, which requires HTTPS with no - * loopback exemption (#2305) — driving the UI needs a self-signed HTTPS - * listener, as `docs/test-servers.md` describes. This endpoint exists for the - * authorization-server side of the flow and for exactly these assertions. + * The document is served over plain HTTP here, on `localhost`. Since #2305 that + * *is* a usable `clientMetadataUrl`: the Inspector's HTTPS requirement now + * exempts the same three loopback literals the SDK exempts for token endpoints, + * so the fixture can be driven from the web client with no self-signed HTTPS + * listener. The last case below pins that, because it is the property the + * fixture's whole reason for existing rests on — a re-tightened validator would + * put the manual repro back out of reach without failing anything else here. */ describe("CIMD showcase fixture (#2242)", () => { let server: TestServerHttp | null = null; @@ -121,6 +127,22 @@ describe("CIMD showcase fixture (#2242)", () => { expect(doc.token_endpoint_auth_method).toBe("none"); }); + it("serves the document at a URL the Inspector accepts as a clientMetadataUrl", async () => { + const started = await startShowcase(); + const documentUrl = `${originOf(started)}/client-metadata.json`; + + // The #2305 property: no self-signed HTTPS listener, no + // NODE_TLS_REJECT_UNAUTHORIZED=0 — the served URL is legal config as-is, + // both inline in the settings form and in `client.json` on disk. + expect(new URL(documentUrl).protocol).toBe("http:"); + expect(getCimdClientMetadataUrlError(documentUrl)).toBeUndefined(); + expect( + parseClientConfig({ + cimd: { enabled: true, clientMetadataUrl: documentUrl }, + }).cimd?.clientMetadataUrl, + ).toBe(documentUrl); + }); + it("preserves a query-bearing document URL in the client_id it publishes", async () => { const started = await startShowcase(); const documentUrl = `${originOf(started)}/client-metadata.json?profile=a`; diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index 82c66f1f..fe783664 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -58,9 +58,48 @@ const HttpUrlStringSchema = z export const CIMD_METADATA_URL_INVALID_ERROR = "Must be a valid URL, like https://example.com/oauth/client.json"; -/** Field-level error when a CIMD metadata URL is not HTTPS. */ +/** + * Field-level error when a CIMD metadata URL is neither HTTPS nor served from + * one of the exempt loopback literals. The message names the exemption rather + * than saying only "must use HTTPS", so a developer pointing the field at a + * local fixture is told which spellings work instead of concluding that nothing + * local can (the same phrasing lesson as the token-endpoint notice in + * `core/auth/oauthUx.ts`). + */ export const CIMD_METADATA_URL_HTTPS_ERROR = - "CIMD client metadata URL must use HTTPS"; + "CIMD client metadata URL must use HTTPS, except on localhost, 127.0.0.1 or [::1]"; + +/** + * Hosts for which a plain `http:` CIMD metadata URL is accepted. + * + * SEP-991 expects HTTPS in production for a real reason: a CIMD `client_id` is + * a URL the authorization server dereferences, and over plain HTTP the document + * it gets back is attacker-modifiable in transit. Loopback is the documented + * exception to exactly that reasoning — there is no network segment to sit on — + * which is why the SDK exempts these same three literals from its token-endpoint + * TLS assertion, and why `core/auth/cimd.ts` already notes that an + * already-stored `client_id` may be an `http://` URL "used by local dev/test + * metadata servers". Without this, the runtime tolerated a value the config + * validation would not let anyone enter, and CIMD could not be driven against + * any fixture in this repo (#2305). + * + * ⚠️ This is the SDK's *three-literal* list, deliberately, and not the broader + * `isLoopbackHost` in `core/node/hostUrl.ts` — which imports `node:net` and so + * cannot be reached from this browser-safe module. Matching the SDK literal for + * literal also keeps the two allow-lists from disagreeing about the same URL. + * `::1` arrives from `URL.hostname` bracketed, which is the form stored here. + */ +const CIMD_HTTP_EXEMPT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * True when a plain-`http:` CIMD metadata URL on this host is acceptable. Takes + * the bare host (`URL.hostname`), never a `host:port`. Not exported: the only + * caller is the validator below, and the exemption is exercised through it + * rather than through a second public surface that could drift from it. + */ +function isCimdHttpExemptHost(hostname: string): boolean { + return CIMD_HTTP_EXEMPT_HOSTS.has(hostname); +} /** Field-level error when a CIMD metadata URL has no path segment. */ export const CIMD_METADATA_URL_PATH_ERROR = @@ -81,7 +120,7 @@ export function getCimdClientMetadataUrlError( } try { const url = new URL(trimmed); - if (url.protocol !== "https:") { + if (url.protocol !== "https:" && !isCimdHttpExemptHost(url.hostname)) { return CIMD_METADATA_URL_HTTPS_ERROR; } if (url.pathname === "/" || url.pathname === "") { diff --git a/docs/test-servers.md b/docs/test-servers.md index 55afa07a..806cace2 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -560,16 +560,23 @@ from **Client settings**, not from a server's own OAuth settings. A `clientMetad catalog entry's `oauth` block is silently ignored — and with `supportDCR: true` the connection then succeeds *via DCR*, which looks like CIMD working until you read the client id. -⚠️ **The Inspector requires that URL to be HTTPS, and there is no loopback exemption** +**Use this server's own document as the `clientMetadataUrl`** — `/client-metadata.json` on the origin +the server announced on startup (`http://127.0.0.1:8092/client-metadata.json` when it got its +configured port; read the announced URL, since this fixture walks upward on `EADDRINUSE` like every +other one here). The Inspector requires HTTPS *except* on the three +loopback literals `localhost`, `127.0.0.1` and `[::1]` (`getCimdClientMetadataUrlError` in `core/client/config-parse.ts`, applied to `client.json` on disk as -well as to the settings form). So this server's own `http://` document is **not** usable as a -`clientMetadataUrl`: it exists for the authorization-server side of the flow and for tests that drive -the AS directly. To drive the Inspector end to end you need the document served over HTTPS — -`https://127.0.0.1:8443/client-metadata.json` from a throwaway self-signed listener works, with -`NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* environment so its own fetch of that document -succeeds. That asymmetry is tracked in -[#2305](https://github.com/modelcontextprotocol/inspector/issues/2305); it is the same over-narrow -allow-list shape as the token-endpoint exemption above. +well as to the settings form), which is the same exemption the SDK applies to token endpoints and what +the runtime already tolerated for an already-stored `client_id`. Give the URL the host the server is +actually listening on; a host outside those three — `localhost.` and `tenant.app.localhost` included, +even though both resolve to loopback — is still rejected, deliberately, so this allow-list and the +SDK's cannot disagree about one URL. + +⚠️ **Before [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) there was no +loopback exemption at all**, so driving this flow meant standing up a throwaway self-signed HTTPS +listener to hold the document and setting `NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* +environment so its own fetch of that document would succeed. That workaround is no longer needed — if +you find it in a script or an older note, delete it. With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the From 2710c98681ae05483eddd09dd684a03178552408 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:44:43 -0400 Subject: [PATCH 007/120] fix: address Copilot review round 2 on #2319 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are on the caller-signal handling added in round 1: - `"signal" in init` treated a member present as `undefined` as an explicit override. `RequestInit` is a WebIDL dictionary, where such a member converts as absent — so `{ ...base, signal: undefined }`, which is what a spread over an options object with no signal produces, was clearing a Request's embedded signal instead of inheriting it. Only a non-`undefined` value overrides now; an explicit `null` is still the genuine no-signal case. - An already-aborted caller signal rejected the race, but `Promise.race` still evaluated `fetchFn(...)`, so on the signal-dropping proxy path the OAuth request went out anyway. It now short-circuits before anything is constructed or sent, and the now-unreachable aborted branch inside the race is gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 25 +++++++++++++++-- core/auth/requestTimeout.ts | 27 ++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 5100083b..190a2640 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -139,13 +139,34 @@ describe("withOAuthRequestTimeout", () => { await expect(pending).rejects.toBe(reason); }); - it("rejects immediately when the caller's signal is already aborted", async () => { - const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); + it("rejects an already-aborted signal without sending the request", async () => { + const inner = vi.fn(neverSettles); + const wrapped = withOAuthRequestTimeout(inner, 60_000); const reason = new Error("already gone"); await expect( wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }), ).rejects.toBe(reason); + // Racing it would still have evaluated the inner fetch, which on the + // signal-dropping proxy path means the request actually goes out. + expect(inner).not.toHaveBeenCalled(); + }); + + it("treats an undefined init.signal as absent, keeping a Request's own", async () => { + // `RequestInit` is a WebIDL dictionary: a member present as `undefined` is + // converted as absent. `{ ...base, signal: undefined }` is what a spread + // over an options object with no signal produces. + const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); + + const caller = new AbortController(); + const pending = wrapped( + new Request(URL_UNDER_TEST, { signal: caller.signal }), + { signal: undefined }, + ); + const reason = new Error("request cancelled"); + caller.abort(reason); + + await expect(pending).rejects.toBe(reason); }); it("honours the signal embedded in a Request input", async () => { diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 8fae3dcb..80cb5aba 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -93,15 +93,19 @@ function requestUrlOf(input: RequestInfo | URL): string { * cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))` * (Copilot). * - * `init.signal` still wins whenever the key is *present*, per the fetch spec: - * an explicit `null` there means "no signal" even when `input` is a `Request` - * that has one. + * An `init.signal` still wins, but only when it is not `undefined`. `RequestInit` + * is a WebIDL dictionary, where a member present with the value `undefined` is + * converted as *absent* — so `{ ...base, signal: undefined }`, which is what a + * spread over an options object that had no signal produces, must inherit the + * `Request`'s signal rather than clear it (Copilot). An explicit `null` is the + * genuine "no signal" override and is honoured as one. */ function callerSignalOf( input: RequestInfo | URL, init: RequestInit | undefined, ): AbortSignal | undefined { - if (init && "signal" in init) return init.signal ?? undefined; + const explicit = init?.signal; + if (explicit !== undefined) return explicit ?? undefined; if (typeof input !== "string" && !(input instanceof URL)) return input.signal; return undefined; } @@ -176,6 +180,12 @@ export function withOAuthRequestTimeout( const url = requestUrlOf(input); const controller = new AbortController(); const callerSignal = callerSignalOf(input, init); + // Before anything is constructed or sent. Racing an already-aborted signal + // would still evaluate `fetchFn(...)`, and on the signal-dropping + // `createRemoteFetch` path that means the OAuth request goes out even + // though the caller cancelled before the call (Copilot). + if (callerSignal?.aborted) throw callerSignal.reason; + const signal = callerSignal ? AbortSignal.any([controller.signal, callerSignal]) : controller.signal; @@ -203,13 +213,10 @@ export function withOAuthRequestTimeout( // whole budget instead of the outer cancellation winning as documented // (Copilot). The caller's own `reason` is preserved, so it still sees its // abort rather than a substituted error. + // Not aborted — the short-circuit above returned for that case. if (callerSignal) { - if (callerSignal.aborted) { - reject(callerSignal.reason); - } else { - onCallerAbort = () => reject(callerSignal.reason); - callerSignal.addEventListener("abort", onCallerAbort, { once: true }); - } + onCallerAbort = () => reject(callerSignal.reason); + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); } }); From 7b9090c024b42ac375089796778d8c007cdfcee7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:57:59 -0400 Subject: [PATCH 008/120] docs: drop the now-stale "tests only" CIMD loopback claim from the auth smoke guide `specification/v2_auth_smoke_testing.md` said `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs "in tests only". That was accurate while the exemption lived only in the runtime's already-stored-`client_id` path; after this PR a loopback `http://` URL is a valid persisted `client.json` setting and a valid entry in the settings form, so the sentence contradicted the workflow the same guide asks a smoke tester to run (Copilot review round 1). The same paragraph's recipe was stale in two other ways it is cheaper to fix here than to leave next to a corrected claim: `cd test-servers && npm run build` names a manifest that does not exist, and `oauth-cimd-only.json` is not a config in this repo. Both now point at the shipped `oauth-cimd-http.json` fixture and the real build command. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- specification/v2_auth_smoke_testing.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index b2621975..c3168e16 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -510,14 +510,16 @@ Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD ### Local fallback (composable test server) -For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the in-repo `TestServerHttp` with `supportCIMD: true` and **`supportDCR: false`** (see `inspectorClient-oauth-e2e.test.ts`). `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs in tests only. +For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the shipped `oauth-cimd-http.json` fixture, which sets `supportCIMD: true` and **`supportDCR: false`** and hosts the client metadata document itself at `/client-metadata.json`. ```bash -cd test-servers && npm run build -node build/server-composable.js --config path/to/oauth-cimd-only.json +cd clients/web && npm run test-servers:build +node test-servers/build/server-composable.js --config test-servers/configs/oauth-cimd-http.json ``` -Example composable OAuth flags: `"supportCIMD": true`, `"supportDCR": false`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and a valid local metadata URL, it should **succeed**. +Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. + +With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. --- From 42139c2d230f8ab77dbabc6d2b1a1d1555828204 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:02:37 -0400 Subject: [PATCH 009/120] fix: put the OAuth deadline inside the compat wrapper (#2319 round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildEffectiveAuthFetch` took `this.fetchFn` as its base, which already carried `withRfc8414OidcCompat` — so the deadline sat outside it. A stalled OIDC probe was then timed on whatever budget the preceding RFC 8414 request had left, and reported under that request's URL rather than the probe's, defeating the endpoint-level diagnostic this change exists to add. The auth chain is now composed separately, deadline innermost, matching the CLI's storedAuthFetch: withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)) `this.fetchFn` keeps the transport chain, unbounded, so an SSE or Streamable HTTP body is still free to stay open. Two tests pin the ordering by contrast: outside, a stalled probe is misreported under the preceding URL; innermost, it is bounded on its own budget and cannot be attributed to the RFC 8414 request. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 73 +++++++++++++++++++ core/mcp/inspectorClient.ts | 38 ++++++---- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 190a2640..df8bbb74 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -4,6 +4,7 @@ import { OAuthRequestTimeoutError, withOAuthRequestTimeout, } from "@inspector/core/auth/requestTimeout.js"; +import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js"; const URL_UNDER_TEST = "https://as.example.com/.well-known/oauth-authorization-server"; @@ -329,6 +330,78 @@ describe("withOAuthRequestTimeout", () => { }); }); + describe("composed under withRfc8414OidcCompat (#2319 ordering)", () => { + // `withRfc8414OidcCompat` re-fetches an OIDC candidate after a failed RFC + // 8414 discovery, so where the deadline sits relative to it decides which + // request is being timed. `InspectorClient` and the CLI both put it + // innermost; these two cases are why. + const RFC8414 = + "https://as.example.com/.well-known/oauth-authorization-server/tenant"; + // A path-suffixed RFC 8414 candidate has two OIDC siblings, and the compat + // wrapper tries both. + const PROBES = [ + "https://as.example.com/.well-known/openid-configuration/tenant", + "https://as.example.com/tenant/.well-known/openid-configuration", + ]; + + /** Answers the RFC 8414 leg with a prompt 404, then stalls on the probe. */ + function stallingProbeFetch() { + return vi.fn((input) => { + if (String(input) === RFC8414) { + return Promise.resolve(new Response(null, { status: 404 })); + } + return new Promise(() => {}); + }); + } + + it("outside, a stalled probe is misreported under the preceding URL", async () => { + vi.useFakeTimers(); + const inner = stallingProbeFetch(); + const wrapped = withOAuthRequestTimeout( + withRfc8414OidcCompat(inner), + 1000, + ); + + const assertion = wrapped(RFC8414).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + const err = (await assertion) as OAuthRequestTimeoutError; + expect(err).toBeInstanceOf(OAuthRequestTimeoutError); + // The endpoint that stalled was PROBE. One budget covers both legs, and + // the error names the request the caller made rather than the one that + // hung — exactly the diagnostic this change exists to provide. + expect(err.url).toBe(RFC8414); + }); + + it("innermost, each leg is bounded on its own and the probe is abandoned", async () => { + vi.useFakeTimers(); + const inner = stallingProbeFetch(); + const wrapped = withRfc8414OidcCompat( + withOAuthRequestTimeout(inner, 1000), + ); + + const settled = wrapped(RFC8414).then( + (response) => response, + (err: unknown) => err, + ); + // The probe is timed from when it starts, on a budget of its own — not on + // whatever an outer one had left after the RFC 8414 leg. + await vi.advanceTimersByTimeAsync(1000); + + const result = await settled; + // The compat wrapper is deliberately inert when a probe *throws* — it + // hands the original response back and lets discovery run its normal + // course — so a timed-out probe surfaces as the original 404 rather than + // as an error, and the second candidate is not tried. What innermost buys + // is that the probe is bounded at all, and that a stall in it can never be + // attributed to the RFC 8414 request the caller actually made. + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(404); + expect(inner).toHaveBeenCalledTimes(2); + expect(String(inner.mock.calls[1][0])).toBe(PROBES[0]); + }); + }); + it("clears its timer once the request settles", async () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(globalThis, "clearTimeout"); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 180243ad..7ccbefa3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -811,8 +811,9 @@ export class InspectorClient extends InspectorClientEventTarget { // `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is // created a few lines below, and `setOAuthConfig` can change them later — so // the wrapper is inert until a server actually configures one. - this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () => - this.oauthManager?.getEndpointOverrides(), + const withOverrides = withOAuthEndpointOverrides( + this.fetchFn ?? fetch, + () => this.oauthManager?.getEndpointOverrides(), ); // #2172: recover discovery when a plain OAuth 2.0 authorization server // publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which @@ -823,8 +824,18 @@ export class InspectorClient extends InspectorClientEventTarget { // still hit the upstream failure. The substituted response is stamped with // `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came // from rather than appearing to be a 200 from the RFC 8414 path. - this.fetchFn = withRfc8414OidcCompat(this.fetchFn); - this.effectiveAuthFetch = this.buildEffectiveAuthFetch(); + this.fetchFn = withRfc8414OidcCompat(withOverrides); + // #2319: the auth chain is composed separately so the deadline sits + // *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put + // it outside, and then a stalled OIDC probe would be reported under the + // URL of the RFC 8414 request that preceded it — defeating the whole point + // of naming the endpoint — while every probe in the loop shared that one + // request's budget (Copilot). Innermost here matches the CLI's + // `storedAuthFetch`. The transport keeps `this.fetchFn`, deliberately + // unbounded: its bodies are streams that are supposed to stay open. + this.effectiveAuthFetch = this.buildEffectiveAuthFetch( + withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)), + ); this.sessionId = options.sessionId; @@ -991,16 +1002,15 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - private buildEffectiveAuthFetch(): typeof fetch { - // #2319: bound every OAuth-path request — discovery, dynamic client - // registration, the token exchange, the refresh. Applied here rather than - // to `this.fetchFn` because the transport is handed `this.fetchFn` - // directly, and a deadline on a Streamable HTTP / SSE response would sever - // the very stream it is supposed to hold open. The cost of that placement - // is that the discovery the SDK runs from *inside* the transport (the - // 401/refresh path) is not covered here; that leg sits inside an SDK - // request and is bounded by its per-request timeout. - const base = withOAuthRequestTimeout(this.fetchFn ?? fetch); + /** + * @param base - the composed OAuth-path fetch, deadline innermost (#2319). + * Passed in rather than read off `this.fetchFn`, which is the *transport* + * chain and must stay unbounded. The cost of keeping the two apart is that + * the discovery the SDK runs from inside the transport (the 401/refresh path) + * is not covered by the deadline; that leg sits inside an SDK request and is + * bounded by its per-request timeout. + */ + private buildEffectiveAuthFetch(base: typeof fetch): typeof fetch { // Capture auth response bodies (OAuth discovery, DCR, token exchange) so // they're inspectable in the Network tab. Token-exchange responses carry // `access_token` / `refresh_token`; the Network UI masks those (and other From a21fd0c161bc68a43a8f11d8591e85b6984c14ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:12:07 -0400 Subject: [PATCH 010/120] docs: state the exemption's canonicalization contract, and fix the smoke recipe's cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2. The exemption compares the WHATWG-canonicalized `URL.hostname`, so it covers alternate spellings of three loopback *addresses* rather than three input strings: `127.1`, `2130706433`, `0x7f.0.0.1`, `[0:0:0:0:0:0:0:1]` and a root-anchored `127.0.0.1.` all pass. The behavior is right — the security argument is about the address the request reaches, and all of these are the loopback interface — but the comment described it as a literal-input match, which it is not. It now states the contract, and six regression cases pin the aliases alongside the existing near-miss cases. This is also still an exact match for the SDK: `assertSecureTokenEndpoint` tests `url.hostname` against the same three values after the same `new URL` canonicalization, so an alias accepted here is one the SDK accepts too. Separately, the smoke guide's fixture recipe left the shell in `clients/web` after the build line, so the repo-root-relative paths on the next line resolved under `clients/web` and the copy-pasted setup failed before the server started. The build now runs in a subshell, and the block notes that the server announces its URL on stderr and walks past a taken port. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../web/src/test/core/client/config.test.ts | 22 ++++++++++++- core/client/config-parse.ts | 31 ++++++++++++++----- specification/v2_auth_smoke_testing.md | 6 +++- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts index 3b6e2663..a3d94239 100644 --- a/clients/web/src/test/core/client/config.test.ts +++ b/clients/web/src/test/core/client/config.test.ts @@ -335,7 +335,27 @@ describe("client config", () => { ); }); - it("does not widen the exemption beyond the three literals", () => { + it("accepts alternate spellings that canonicalize to the same addresses", () => { + // The check runs against the WHATWG-canonicalized `URL.hostname`, so the + // exemption covers these three *addresses*, not three input strings. Each + // of these reaches the loopback interface, which is what the security + // argument is about — and the SDK canonicalizes identically, so an alias + // accepted here is one it accepts for a token endpoint too. + for (const value of [ + "http://127.1:8090/client-metadata.json", + "http://2130706433:8090/client-metadata.json", + "http://0x7f.0.0.1:8090/client-metadata.json", + "http://[0:0:0:0:0:0:0:1]:8090/client-metadata.json", + // WHATWG strips a root-anchored dot from an IP literal but not from a + // name, which is why this passes where `localhost.` below does not. + "http://127.0.0.1.:8090/client-metadata.json", + "http://LOCALHOST:8090/client-metadata.json", + ]) { + expect(getCimdClientMetadataUrlError(value)).toBeUndefined(); + } + }); + + it("does not widen the exemption beyond those three addresses", () => { // `localhost.` and `*.localhost` resolve to loopback on every resolver, // but they are outside the SDK's allow-list — so they are outside ours // too, deliberately, rather than the two disagreeing about one URL. diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index fe783664..48f762d4 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -83,19 +83,34 @@ export const CIMD_METADATA_URL_HTTPS_ERROR = * validation would not let anyone enter, and CIMD could not be driven against * any fixture in this repo (#2305). * - * ⚠️ This is the SDK's *three-literal* list, deliberately, and not the broader - * `isLoopbackHost` in `core/node/hostUrl.ts` — which imports `node:net` and so - * cannot be reached from this browser-safe module. Matching the SDK literal for - * literal also keeps the two allow-lists from disagreeing about the same URL. - * `::1` arrives from `URL.hostname` bracketed, which is the form stored here. + * ⚠️ This is the SDK's own list, matched member for member — not the broader + * `isLoopbackHost` in `core/node/hostUrl.ts`, which imports `node:net` and so + * cannot be reached from this browser-safe module. The SDK's + * `assertSecureTokenEndpoint` tests `url.hostname` against exactly + * `localhost` / `127.0.0.1` / `[::1]` (plus a `::1` arm `URL.hostname` never + * produces, since it brackets IPv6 literals). Keeping the two identical is what + * stops them disagreeing about one URL. + * + * ⚠️ **The comparison is against the WHATWG-canonicalized `URL.hostname`, so it + * covers alternate spellings of these three *addresses*, not three input + * strings.** `http://127.1/…`, `http://2130706433/…`, `http://0x7f.0.0.1/…` and + * `http://[0:0:0:0:0:0:0:1]/…` all canonicalize into the set and are accepted; + * so is a root-anchored `http://127.0.0.1./…`, which WHATWG strips the dot from + * for IP literals but *not* for `localhost.`. That is deliberate on both counts: + * the security argument is about the address the request actually reaches, and + * every one of these is the loopback interface. It is also exactly what the SDK + * does — it canonicalizes through `new URL` the same way — so an alias accepted + * here is an alias the SDK accepts too. */ const CIMD_HTTP_EXEMPT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); /** * True when a plain-`http:` CIMD metadata URL on this host is acceptable. Takes - * the bare host (`URL.hostname`), never a `host:port`. Not exported: the only - * caller is the validator below, and the exemption is exercised through it - * rather than through a second public surface that could drift from it. + * the already-canonicalized `URL.hostname`, never a raw authority or a + * `host:port` — passing an unparsed string would compare the wrong thing and + * silently reject every alias above. Not exported: the only caller is the + * validator below, and the exemption is exercised through it rather than + * through a second public surface that could drift from it. */ function isCimdHttpExemptHost(hostname: string): boolean { return CIMD_HTTP_EXEMPT_HOSTS.has(hostname); diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index c3168e16..340e1b84 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -513,10 +513,14 @@ Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the shipped `oauth-cimd-http.json` fixture, which sets `supportCIMD: true` and **`supportDCR: false`** and hosts the client metadata document itself at `/client-metadata.json`. ```bash -cd clients/web && npm run test-servers:build +# The build script lives in clients/web; both paths below are repo-root-relative, +# so come back up before starting the server. +(cd clients/web && npm run test-servers:build) node test-servers/build/server-composable.js --config test-servers/configs/oauth-cimd-http.json ``` +The server announces its URL on **stderr**, and walks to the next free port on `EADDRINUSE` — read the announced URL rather than assuming 8092. + Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. From d8483000775360246797f7bd9245e1be014dd8f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:23:26 -0400 Subject: [PATCH 011/120] fix: enforce the OAuth deadline across the proxy hop (#2319 round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both real. The race bounded only the caller's promise on the browser path. `createRemoteFetch` dropped the signal, so an abort — including the deadline's own — left the POST to /api/fetch in flight; the backend never saw its request cancelled and its outbound fetch ran on with neither signal nor timeout, holding a handler and an authorization-server socket per timed-out attempt. `createRemoteFetch` now forwards the caller's signal (init.signal when present and not undefined, else a Request's own), and the /api/fetch route composes `c.req.raw.signal` with a server-side deadline matching the client budget, cleared in `finally` so a streaming response is not severed. A probe's own timeout was swallowed by `withRfc8414OidcCompat`, which returned the preceding 404 and discarded the endpoint name the deadline exists to provide. A deadline the Inspector imposed is its own error, not the server's answer, so it escapes now; every other probe failure still falls back as documented. Nothing regresses: every candidate the loop probes is one the SDK's own buildDiscoveryUrls emits, so a stall there is one the SDK would have hit itself, unbounded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 23 ++---- .../core/mcp/remote/createRemoteFetch.test.ts | 73 +++++++++++++++++++ core/auth/oidcDiscoveryCompat.ts | 14 +++- core/mcp/remote/createRemoteFetch.ts | 17 +++++ core/mcp/remote/node/server.ts | 31 ++++++++ 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index df8bbb74..ab453c52 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -373,32 +373,25 @@ describe("withOAuthRequestTimeout", () => { expect(err.url).toBe(RFC8414); }); - it("innermost, each leg is bounded on its own and the probe is abandoned", async () => { + it("innermost, the probe's own timeout escapes and names the probe", async () => { vi.useFakeTimers(); const inner = stallingProbeFetch(); const wrapped = withRfc8414OidcCompat( withOAuthRequestTimeout(inner, 1000), ); - const settled = wrapped(RFC8414).then( - (response) => response, - (err: unknown) => err, - ); + const assertion = wrapped(RFC8414).catch((err: unknown) => err); // The probe is timed from when it starts, on a budget of its own — not on // whatever an outer one had left after the RFC 8414 leg. await vi.advanceTimersByTimeAsync(1000); - const result = await settled; - // The compat wrapper is deliberately inert when a probe *throws* — it - // hands the original response back and lets discovery run its normal - // course — so a timed-out probe surfaces as the original 404 rather than - // as an error, and the second candidate is not tried. What innermost buys - // is that the probe is bounded at all, and that a stall in it can never be - // attributed to the RFC 8414 request the caller actually made. - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(404); + const err = (await assertion) as OAuthRequestTimeoutError; + // The compat wrapper is inert on an ordinary probe failure, but a deadline + // the Inspector imposed escapes it, so the caller is told which endpoint + // stalled rather than being handed the preceding 404. + expect(err).toBeInstanceOf(OAuthRequestTimeoutError); + expect(err.url).toBe(PROBES[0]); expect(inner).toHaveBeenCalledTimes(2); - expect(String(inner.mock.calls[1][0])).toBe(PROBES[0]); }); }); diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index 7ae9edbf..ea0d7f66 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -168,4 +168,77 @@ describe("createRemoteFetch", () => { expect(res.headers.get("x-test")).toBe("yes"); expect(await res.text()).toBe("hello"); }); + + describe("cancellation forwarding (#2319)", () => { + // Without this the caller's abort settled only its own promise: the POST + // stayed in flight, the backend never saw its request cancelled, and its + // outbound fetch to the authorization server ran on detached. + function capture() { + const fetchFn = vi + .fn() + .mockResolvedValue(ok(standardRemoteBody)); + const remoteFetch = createRemoteFetch({ + baseUrl: "http://remote.example", + fetchFn: fetchFn as unknown as typeof fetch, + }); + return { fetchFn, remoteFetch }; + } + + function signalOf(fetchFn: ReturnType>) { + return (fetchFn.mock.calls[0][1] as RequestInit).signal; + } + + it("forwards init.signal onto the proxy hop", async () => { + const { fetchFn, remoteFetch } = capture(); + const caller = new AbortController(); + + await remoteFetch("http://upstream.example/", { signal: caller.signal }); + + expect(signalOf(fetchFn)).toBe(caller.signal); + }); + + it("forwards a Request's own signal when init has none", async () => { + const { fetchFn, remoteFetch } = capture(); + const caller = new AbortController(); + + await remoteFetch( + new Request("http://upstream.example/", { signal: caller.signal }), + ); + + expect(signalOf(fetchFn)).toBe(caller.signal); + }); + + it("treats an undefined init.signal as absent, keeping the Request's", async () => { + // WebIDL dictionary conversion: a member present as `undefined` is absent. + const { fetchFn, remoteFetch } = capture(); + const caller = new AbortController(); + + await remoteFetch( + new Request("http://upstream.example/", { signal: caller.signal }), + { signal: undefined }, + ); + + expect(signalOf(fetchFn)).toBe(caller.signal); + }); + + it("honours an explicit null init.signal as no signal", async () => { + const { fetchFn, remoteFetch } = capture(); + const caller = new AbortController(); + + await remoteFetch( + new Request("http://upstream.example/", { signal: caller.signal }), + { signal: null }, + ); + + expect(signalOf(fetchFn)).toBeUndefined(); + }); + + it("sends no signal when the caller supplied none", async () => { + const { fetchFn, remoteFetch } = capture(); + + await remoteFetch("http://upstream.example/"); + + expect(signalOf(fetchFn)).toBeUndefined(); + }); + }); }); diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index bb30e722..4404adc0 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -90,6 +90,7 @@ * wiring in `core/mcp/inspectorClient.ts`. */ +import { OAuthRequestTimeoutError } from "./requestTimeout.js"; import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, @@ -262,7 +263,18 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { let probe: Response; try { probe = await fetchFn(candidate, { headers }); - } catch { + } catch (err) { + // A deadline the Inspector imposed is *our* error, not the server's + // answer, so it escapes rather than being folded back into the original + // response (#2319, Copilot). Swallowing it would hand the caller the + // preceding 404 and discard the one thing the deadline adds — the name + // of the endpoint that stalled — leaving discovery to fail later under + // a message that points at the wrong URL. There is no flow this can + // break that was not already broken: every candidate this loop probes + // is one the SDK's own `buildDiscoveryUrls` emits, so a stall here is a + // stall the SDK would have hit itself, unbounded. Everything else — a + // CORS rejection, DNS, a reset — still falls back as documented above. + if (err instanceof OAuthRequestTimeoutError) throw err; return response; } if (!probe.ok) { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 4dcbf689..36bbba2d 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -133,10 +133,27 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { reqHeaders["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; } + // #2319: forward the caller's cancellation onto the proxy hop. Without it + // an abort — including the OAuth deadline's own — settled only the caller's + // promise: the POST stayed in flight, so the backend never saw its request + // cancelled and its outbound fetch to the authorization server was left + // running detached, holding a handler and a socket for as long as the + // server cared to stall (Copilot). `init.signal` wins when present and not + // `undefined` (a WebIDL dictionary member present as `undefined` converts + // as absent), otherwise a `Request` carries its own. + const explicitSignal = init?.signal; + const signal = + explicitSignal !== undefined + ? (explicitSignal ?? undefined) + : input instanceof Request + ? input.signal + : undefined; + const res = await fetchFn(`${baseUrl}/api/fetch`, { method: "POST", headers: reqHeaders, body: JSON.stringify({ url, method, headers, body }), + signal, }); if (!res.ok) { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 69439f6d..071bcd49 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -28,6 +28,7 @@ import { bodyLimit } from "hono/body-limit"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; import { createProxyFetch } from "../../node/proxyFetch.js"; +import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS } from "../../../auth/requestTimeout.js"; import type { RemoteConnectRequest, RemoteSendRequest, @@ -1216,6 +1217,33 @@ export function createRemoteApp( return c.json({ error: "Missing url" }, 400); } + // #2319: bound the outbound call, and release it when the browser gives up. + // This hop is where a client-side deadline used to stop being enforceable: + // the browser abandons its promise, but the request it made is served here, + // and an outbound fetch with neither a signal nor a timeout holds a handler + // and an authorization-server socket open indefinitely — once per timed-out + // retry (Copilot). `c.req.raw.signal` is the propagated cancellation, now + // that `createRemoteFetch` forwards it; the timer is the backstop for a + // client that vanishes without aborting, and it matches the client-side + // budget so the two agree on when this is hopeless. + const controller = new AbortController(); + const clientSignal: AbortSignal | undefined = c.req.raw.signal; + const signal = clientSignal + ? AbortSignal.any([controller.signal, clientSignal]) + : controller.signal; + // Cleared in `finally`, before the handler returns. A streaming response is + // returned here without its body being read, so a timer left armed would + // sever a live stream 30 seconds in. + const timer = setTimeout( + () => + controller.abort( + new Error( + `proxied request to ${url} timed out after ${DEFAULT_OAUTH_REQUEST_TIMEOUT_MS}ms`, + ), + ), + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + ); + try { // Proxy-aware, not the bare global. This route is the browser's ONLY way // out to the network: the web client's `environment.fetch` is @@ -1228,6 +1256,7 @@ export function createRemoteApp( method, headers: new Headers(headers), body: reqBody, + signal, }); const resHeaders: Record = {}; @@ -1254,6 +1283,8 @@ export function createRemoteApp( } catch (err) { const msg = err instanceof Error ? err.message : String(err); return c.json({ error: msg }, 500); + } finally { + clearTimeout(timer); } }); From d7f90144c661d1260a642e9762d2e30af3ca1a90 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:32:02 -0400 Subject: [PATCH 012/120] docs: make the local CIMD smoke deterministic, and fix two HTTPS-only claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4, four findings, all correct. The CIMD-off / CIMD-on pair the smoke guide documents is not a control without clearing OAuth state first: tokens and the registered client persist in oauth.json independently of the install-wide CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant from an earlier CIMD-on run makes the CIMD-off phase connect and the negative control silently proves nothing. Both the spec's pair and the walkthrough in docs/test-servers.md now say to clear state (or isolate MCP_STORAGE_DIR) first. The composable server announces its MCP endpoint, not the metadata document — `server-composable.ts` prints a URL ending in `/mcp`. The guide called the announced URL a metadata URL; it now says to take the origin from that line and append `/client-metadata.json`. Two general statements elsewhere in the same file still defined CIMD as always HTTPS (the glossary row, and the Stytch section's opening), so a reader following them would have rejected the loopback fixture the same file now documents. Both note the exemption; the Stytch examples stay HTTPS, which they are. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- docs/test-servers.md | 8 ++++++++ specification/v2_auth_smoke_testing.md | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/test-servers.md b/docs/test-servers.md index 806cace2..50fa455d 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -578,6 +578,14 @@ listener to hold the document and setting `NODE_TLS_REJECT_UNAUTHORIZED=0` in th environment so its own fetch of that document would succeed. That workaround is no longer needed — if you find it in a script or an older note, delete it. +⚠️ **Clear OAuth state before the run if you have connected to this fixture before.** Tokens and the +registered client persist in `~/.mcp-inspector/storage/oauth.json` independently of the install-wide +CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant can +carry a run that the registration path never actually completed, which is the same +"it connected, therefore CIMD worked" trap `supportDCR: false` exists to close. Use **Clear OAuth +state and disconnect** (Server Settings → Authorization), or point `MCP_STORAGE_DIR` at a throwaway +directory, which additionally survives a restarted fixture having forgotten a client it once issued. + With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the metadata URL**, which is what CIMD means and what distinguishes it from a DCR-issued diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index 340e1b84..810eb538 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -40,7 +40,7 @@ Web uses `http://localhost:6274/oauth/callback` on the main app server — not t | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. | | **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. | -| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. | +| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported` — or a plain `http://` one on `localhost` / `127.0.0.1` / `[::1]`, the loopback exemption added in [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) for local fixtures. | | **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). | ## Prerequisites @@ -395,7 +395,7 @@ The full `redirect_uris` list also includes other localhost ports and MCPJam app #### Why MCPJam’s document works with Inspector -CIMD treats the metadata **HTTPS URL** as the OAuth `client_id`. On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks: +CIMD treats the metadata **HTTPS URL** as the OAuth `client_id` (loopback `http://` is the one exemption — see [§ local fallback](#local-fallback-composable-test-server); everything in this Stytch section is HTTPS). On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks: 1. The document’s `client_id` field matches the URL. 2. The `redirect_uri` on the request appears **exactly** in `redirect_uris`. @@ -521,9 +521,13 @@ node test-servers/build/server-composable.js --config test-servers/configs/oauth The server announces its URL on **stderr**, and walks to the next free port on `EADDRINUSE` — read the announced URL rather than assuming 8092. -Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. +Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So `clientMetadataUrl` can point straight at the document this fixture serves, with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. -With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. +⚠️ **The server announces its MCP endpoint, not the metadata URL** — the line on stderr ends in `/mcp`. Take the **origin** (host and port) from it and append `/client-metadata.json`: an announced `http://127.0.0.1:8092/mcp` means `clientMetadataUrl` is `http://127.0.0.1:8092/client-metadata.json`. Don't assume 8092; the fixture walks past a taken port. + +⚠️ **Clear OAuth state before each phase, or neither outcome below is deterministic.** Tokens and the registered client persist in `~/.mcp-inspector/storage/oauth.json` independently of the install-wide CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant from an earlier CIMD-on run makes the CIMD-off phase *connect*, and the negative control silently proves nothing. Use **Clear OAuth state and disconnect** (Server Settings → Authorization) before the off phase and again between the off and on phases. Pointing `MCP_STORAGE_DIR` at a throwaway directory for the whole run is the stronger version, and also survives a restarted fixture having forgotten a client it once issued. + +With that state cleared: with CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. --- From f3e4023e350724cde802e5d41a4bfbf5b612347b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:43:57 -0400 Subject: [PATCH 013/120] fix: sanitize rebuilt headers, refresh stale docs, cover the route (#2319 round 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all real. `arrayBuffer()` hands back the decoded entity body, so the rebuilt response was carrying the upstream `content-encoding` and the compressed `content-length` — a gzipped metadata document came back as plain JSON bytes still claiming gzip and the wrong size, visible in the Network capture. Both headers are deleted now, as `responseWithBody` in endpointOverrides.ts already did for the same reason. The "why a race, and not just the signal" rationale described a world this PR had since changed: the signal does now reach the backend's outbound fetch. The module doc and three implementation comments are rewritten to say what the race is actually still for — a fetchFn that ignores AbortSignal, and a backend that is itself wedged — rather than a proxy hop that no longer drops it. The backend cancellation path had only mock-level coverage. A route-level test drives /api/fetch at an upstream that accepts and never answers, aborts the browser side, and asserts the upstream observes its connection closing. Verified by mutation: dropping `signal` from the route's outbound fetch makes it time out rather than pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 48 +++++++++++--- .../mcp/remote/server-extra-coverage.test.ts | 47 ++++++++++++++ core/auth/requestTimeout.ts | 65 ++++++++++++------- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index ab453c52..5308437d 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -96,11 +96,12 @@ describe("withOAuthRequestTimeout", () => { expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); }); - it("still enforces the deadline when the signal is dropped in flight", async () => { + it("still enforces the deadline when the fetch ignores the signal", async () => { vi.useFakeTimers(); - // The browser's `createRemoteFetch` re-issues the call to `/api/fetch` and - // forwards no signal, so aborting locally cancels nothing. The race is what - // has to bound it. + // The signal now reaches all the way out — `createRemoteFetch` forwards it + // onto the proxy hop and `/api/fetch` composes it into its outbound call — + // but a `fetchFn` that ignores `AbortSignal` cancels nothing, so the race + // is what bounds this case. const signalIgnoring: typeof fetch = () => new Promise(() => {}); const wrapped = withOAuthRequestTimeout(signalIgnoring, 1000); @@ -127,9 +128,9 @@ describe("withOAuthRequestTimeout", () => { }); it("races caller cancellation too, so it wins on a signal-ignoring fetch", async () => { - // Forwarding alone is not enough on the browser's `createRemoteFetch` path, - // which drops the signal: without the race the caller's abort would sit - // pending for the whole budget instead of winning. + // Forwarding alone is not enough against a `fetchFn` that ignores the + // signal: without the race the caller's abort would sit pending for the + // whole budget instead of winning. const wrapped = withOAuthRequestTimeout(neverSettles, 60_000); const caller = new AbortController(); @@ -148,8 +149,8 @@ describe("withOAuthRequestTimeout", () => { await expect( wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }), ).rejects.toBe(reason); - // Racing it would still have evaluated the inner fetch, which on the - // signal-dropping proxy path means the request actually goes out. + // Racing it would still have evaluated the inner fetch, and a fetch that + // does not check an already-aborted signal would send the request. expect(inner).not.toHaveBeenCalled(); }); @@ -257,6 +258,8 @@ describe("withOAuthRequestTimeout", () => { expect(response.statusText).toBe("Created"); expect(response.ok).toBe(true); expect(response.headers.get("x-probe")).toBe("kept"); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-length")).toBeNull(); expect(response.url).toBe(original.url); expect(response.redirected).toBe(original.redirected); expect(response.type).toBe(original.type); @@ -265,6 +268,33 @@ describe("withOAuthRequestTimeout", () => { }); }); + it("drops content-encoding and content-length, which the buffer invalidates", async () => { + // `arrayBuffer()` hands back the *decoded* entity body, so an inherited + // `content-encoding` would describe an encoding the bytes no longer have + // and the inherited `content-length` would be the compressed size. Both are + // wrong, and both show up in the Network capture. + const decoded = '{"issuer":"https://as.example.com"}'; + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue( + new Response(decoded, { + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "42", + }, + }), + ), + 1000, + ); + + const response = await wrapped(URL_UNDER_TEST); + + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-length")).toBeNull(); + expect(response.headers.get("content-type")).toBe("application/json"); + await expect(response.text()).resolves.toBe(decoded); + }); + it("rebuilds a null-body status without handing it a body", async () => { // `new Response(body, { status: 204 })` throws; the rebuild has to pass null. const wrapped = withOAuthRequestTimeout( diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index aabda5f2..f1d6ef9c 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -300,6 +300,10 @@ describe("server.ts supplemental coverage", () => { let h: Harness; let target: ServerType; let targetUrl: string; + /** Resolves when an upstream `/stall` request is closed by its client. */ + let stallClosed: Promise; + /** Paths the upstream fixture has actually received. */ + let upstreamHits: string[]; // Isolate the WHOLE suite from the developer's (or CI's) proxy environment. // Setting only uppercase HTTP_PROXY in the one proxy test would not be @@ -336,7 +340,21 @@ describe("server.ts supplemental coverage", () => { h = await start(); // A tiny upstream HTTP server we can point /api/fetch at. const { createServer } = await import("node:http"); + let markClosed: () => void = () => {}; + stallClosed = new Promise((resolve) => { + markClosed = resolve; + }); + upstreamHits = []; const srv = createServer((req, res) => { + upstreamHits.push(req.url ?? ""); + if (req.url === "/stall") { + // Accept the request, answer nothing, and report when the client + // gives up — the #2319 wedged-authorization-server shape. `close` on + // the response fires whether the socket was torn down or the handler + // simply ended, and nothing here ever ends it. + res.on("close", () => markClosed()); + return; + } if (req.url === "/stream") { res.writeHead(200, { "Content-Type": "text/event-stream" }); res.write("data: hi\n\n"); @@ -381,6 +399,35 @@ describe("server.ts supplemental coverage", () => { await stop(h); }); + it("releases the upstream request when the browser cancels (#2319)", async () => { + // The point of forwarding the signal through `createRemoteFetch`: without + // it the browser's abort settled only its own promise, and this handler + // plus the upstream socket stayed pending for as long as the server cared + // to stall — once per timed-out attempt. Asserted at the route rather + // than on a mock, because the mock cannot show the socket being released. + const caller = new AbortController(); + const pending = fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stall` }), + signal: caller.signal, + }); + const rejected = pending.catch((err: unknown) => err); + + // Wait until the upstream has actually received the request, so the abort + // cannot race ahead of it and pass for the wrong reason. + await vi.waitFor(() => { + expect(upstreamHits).toContain("/stall"); + }); + caller.abort(); + + expect(await rejected).toBeInstanceOf(Error); + // The upstream sees its connection go away. Without the signal composed + // into the route's outbound fetch this never resolves and the test times + // out. + await stallClosed; + }); + it("routes the outbound request through HTTP_PROXY (#2067)", async () => { // /api/fetch is the browser's ONLY way out to the network — the web // client's `environment.fetch` is `createRemoteFetch()`, which forwards diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 80cb5aba..7c2dadf0 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -36,17 +36,23 @@ * too, and — the point of naming the URL in the error — says *which* endpoint * stalled instead of blaming the handshake. * - * ## Why a race, and not just the signal + * ## The signal, the proxy hop, and why the race is still here * - * The signal alone cannot enforce this. In the browser the OAuth fetch is - * `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch` and - * does not forward `init.signal`; the backend's outbound fetch gets no signal - * either. So on exactly the path this bound exists for — a browser session - * against a wedged authorization server — aborting the local promise would - * change nothing. The race is what actually enforces the deadline; the signal - * is kept because it does cancel the direct-fetch paths (CLI, TUI, backend) - * rather than merely abandoning them. Same reasoning, same shape, as - * `revokeToken`. + * The signal is the primary mechanism and it now reaches all the way out. On a + * direct fetch (CLI, TUI, backend) it cancels the request outright. In the + * browser the OAuth fetch is `createRemoteFetch`, which re-issues the call as a + * POST to `/api/fetch` — and as of #2319 it forwards the signal onto that hop, + * where the route composes `c.req.raw.signal` into its own outbound fetch. So + * an abort here tears down the backend's request to the authorization server + * rather than leaving it running detached, which is what it used to do. + * + * The race is kept anyway, and it is not redundant. It bounds what the signal + * cannot: a `fetchFn` that ignores `AbortSignal` altogether — an injected or + * test double, or any future transport that drops it the way the proxy used to + * — and a backend that is itself wedged, where the outbound request is + * cancelled but the hop back never completes. A deadline that depends on every + * layer beneath it honouring a signal is not a deadline. Same reasoning, same + * shape, as `revokeToken`. */ /** @@ -120,19 +126,32 @@ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); /** * Rebuild a `Response` around an already-buffered body. * + * Two things have to be carried across by hand. + * * `url`, `redirected` and `type` are getters with no constructor option, and * callers do read them — the RFC 8414/OIDC compat wrapper stamps a header * naming the URL a body came from, and the SDK's discovery reports the - * responding URL in its errors. So they are copied across explicitly rather - * than silently reset to `""` / `false` / `"default"`. + * responding URL in its errors. So they are copied explicitly rather than + * silently reset to `""` / `false` / `"default"`. + * + * `content-encoding` and `content-length`, by contrast, must be *dropped*. + * `arrayBuffer()` hands back the **decoded** entity body, so a gzipped metadata + * document arrives here as plain JSON bytes: an inherited `content-encoding` + * would describe an encoding the body no longer has, and the inherited + * `content-length` would be the compressed size — both of them wrong, and both + * visible in the Network capture (Copilot). `responseWithBody` in + * `endpointOverrides.ts` deletes the same two headers for the same reason. */ function rebuildResponse(response: Response, body: ArrayBuffer): Response { + const headers = new Headers(response.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); const rebuilt = new Response( NULL_BODY_STATUSES.has(response.status) ? null : body, { status: response.status, statusText: response.statusText, - headers: response.headers, + headers, }, ); for (const key of ["url", "redirected", "type"] as const) { @@ -181,9 +200,10 @@ export function withOAuthRequestTimeout( const controller = new AbortController(); const callerSignal = callerSignalOf(input, init); // Before anything is constructed or sent. Racing an already-aborted signal - // would still evaluate `fetchFn(...)`, and on the signal-dropping - // `createRemoteFetch` path that means the OAuth request goes out even - // though the caller cancelled before the call (Copilot). + // would still evaluate `fetchFn(...)`, and a `fetchFn` that does not check + // an already-aborted signal — the proxy hop was one until #2319 — would + // send the OAuth request even though the caller cancelled before the call + // (Copilot). if (callerSignal?.aborted) throw callerSignal.reason; const signal = callerSignal @@ -199,18 +219,17 @@ export function withOAuthRequestTimeout( const abandoned = new Promise((_resolve, reject) => { timer = setTimeout(() => { timedOut = true; - // Abort first so a direct fetch is actually cancelled rather than left - // running detached, then reject so the proxied fetch — which never saw - // the signal — is abandoned on schedule too. + // Abort first, which is what actually cancels the request — through + // the proxy hop too, since #2319 — then reject, so a `fetchFn` that + // ignores the signal is still abandoned on schedule. controller.abort(new OAuthRequestTimeoutError(url, budget)); reject(new OAuthRequestTimeoutError(url, budget)); }, budget); // The caller's cancellation is raced too, not merely forwarded. Forwarding - // it is enough on a direct fetch, but on the browser's `createRemoteFetch` - // path — the one this wrapper exists for — the signal is dropped in - // flight, so an abort would otherwise leave this promise pending for the - // whole budget instead of the outer cancellation winning as documented + // it is enough wherever the signal is honoured, but a `fetchFn` that + // ignores it would otherwise leave this promise pending for the whole + // budget instead of the outer cancellation winning as documented // (Copilot). The caller's own `reason` is preserved, so it still sees its // abort rather than a substituted error. // Not aborted — the short-circuit above returned for that case. From 8baa1216fd387c99539674ef51a610e2e74abce9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:48:54 -0400 Subject: [PATCH 014/120] test: pin the CIMD form copy; narrow the "no exemption" claim in the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 5. Two of three findings taken; the third rests on a false premise about the SDK and is declined with evidence in the PR thread. The hint and the input description are part of this fix's user-facing contract — an HTTPS-only instruction there puts the local-fixture workflow back out of reach even while the validator stays permissive — and nothing asserted them. Two cases now do, plus one asserting a loopback http URL raises no error in the form. Each mutation (restoring either piece of old copy) fails exactly one of them. The docs said there was "no loopback exemption at all" before #2305, which contradicts the sentence above it: the runtime already tolerated an `http://` already-stored `client_id`, and that asymmetry is what the issue is about. The gap was in the config/form validator, and the wording now says so. Also added: a note to use the canonical spelling of the host. `127.1` and `2130706433` are accepted here and by the SDK, since both canonicalize to `127.0.0.1` — but a CIMD `client_id` is string-compared by the authorization server against the URL it dereferenced, so an exotic spelling is a real footgun independent of what either validator allows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ClientSettingsForm.test.tsx | 54 +++++++++++++++++++ docs/test-servers.md | 19 +++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 9f5c92ce..0c472f8a 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -312,6 +312,60 @@ describe("ClientSettingsForm EMA IdP session", () => { expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument(); }); + it("tells the reader the loopback exemption exists, in both pieces of CIMD copy", () => { + renderWithMantine( + , + ); + + // The copy is part of this feature's contract, not decoration: the whole + // point of #2305 is that a local fixture URL is enterable, and an + // HTTPS-only instruction here puts that back out of reach for a reader + // even while the validator stays permissive. Both places that state the + // requirement are asserted, since either one alone would still mislead. + const hint = screen.getByText(/must be served over HTTPS/); + expect(hint).toHaveTextContent("localhost, 127.0.0.1 or [::1]"); + + expect( + screen.getByText(/HTTPS URL of your OAuth client metadata JSON document/), + ).toHaveTextContent( + "http:// is accepted only on localhost, 127.0.0.1 or [::1]", + ); + }); + + it("shows no CIMD URL error for a loopback http URL", () => { + renderWithMantine( + , + ); + + expect( + screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR), + ).not.toBeInTheDocument(); + }); + it("shows no CIMD URL error for a valid URL", () => { renderWithMantine( Date: Fri, 11 Sep 2026 04:00:44 -0400 Subject: [PATCH 015/120] fix: cancel the discarded proxy stream, refresh revocation docs (#2319 round 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/fetch route classifies an event-stream or NDJSON response as a stream and then answers with JSON and no body — so nobody downstream owns the upstream stream and nothing will ever read it, while the `finally` clears the only deadline on the way out. It is now cancelled explicitly, best-effort, the way `releaseBody` in oidcDiscoveryCompat already does. Measured, undici reclaims an unread body here on its own, so this is belt and braces rather than a demonstrated leak — kept because relying on that is an implementation detail of the fetch beneath us, not a contract. The route test and the source comment both say so rather than implying a stronger claim. Forwarding the signal also made revocation.ts's rationale stale: it still described a proxy that drops `init.signal` and a backend that cannot cancel. Both comments there, and the matching one in revocation.test.ts, now describe the post-#2319 path — the signal cancels, the race guarantees, and what the race is still for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../web/src/test/core/auth/revocation.test.ts | 10 ++--- .../mcp/remote/server-extra-coverage.test.ts | 42 +++++++++++++++++++ core/auth/revocation.ts | 28 +++++++------ core/mcp/remote/node/server.ts | 20 +++++++-- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/clients/web/src/test/core/auth/revocation.test.ts b/clients/web/src/test/core/auth/revocation.test.ts index e50fa1e9..c108b530 100644 --- a/clients/web/src/test/core/auth/revocation.test.ts +++ b/clients/web/src/test/core/auth/revocation.test.ts @@ -393,11 +393,11 @@ describe("revokeToken", () => { }); }); - // The web fetch is `createRemoteFetch`, which re-issues the call as a POST to - // `/api/fetch` and drops `init.signal`; the backend's outbound fetch gets no - // signal either. A signal-only bound is therefore inert on exactly the path - // the timeout exists for, so the deadline has to hold against a fetch that - // ignores the signal entirely. + // The signal reaches every path since #2319 — `createRemoteFetch` forwards it + // onto the `/api/fetch` hop and that route composes it into its outbound + // fetch — but a signal-only bound is still only as good as the fetch beneath + // it. A `fetchFn` that ignores `AbortSignal`, or a wedged backend, would + // leave a signal-only deadline inert, so it has to hold without one. it("gives up on a fetch that never settles and ignores the signal", async () => { const outcome = await revokeToken({ endpoint: REVOKE_URL, diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index f1d6ef9c..648da1c9 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -304,6 +304,8 @@ describe("server.ts supplemental coverage", () => { let stallClosed: Promise; /** Paths the upstream fixture has actually received. */ let upstreamHits: string[]; + /** Resolves when the never-ending `/stream-open` response is closed. */ + let openStreamClosed: Promise; // Isolate the WHOLE suite from the developer's (or CI's) proxy environment. // Setting only uppercase HTTP_PROXY in the one proxy test would not be @@ -345,8 +347,22 @@ describe("server.ts supplemental coverage", () => { markClosed = resolve; }); upstreamHits = []; + let markStreamClosed: () => void = () => {}; + openStreamClosed = new Promise((resolve) => { + markStreamClosed = resolve; + }); const srv = createServer((req, res) => { upstreamHits.push(req.url ?? ""); + if (req.url === "/stream-open") { + // Event-stream headers, one frame, and then nothing — the shape that + // used to leave a socket open for good, because the route classifies + // it as a stream, returns JSON without the body, and clears its + // deadline on the way out. + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("data: hi\n\n"); + res.on("close", () => markStreamClosed()); + return; + } if (req.url === "/stall") { // Accept the request, answer nothing, and report when the client // gives up — the #2319 wedged-authorization-server shape. `close` on @@ -428,6 +444,32 @@ describe("server.ts supplemental coverage", () => { await stallClosed; }); + it("cancels a discarded stream that sends headers and never ends (#2319)", async () => { + // The route answers with JSON and no body, so nobody downstream owns the + // upstream stream and nothing will ever read it; the route cancels it + // explicitly rather than relying on that being reclaimed for it. + // + // ⚠️ This pins the OUTCOME, not the mechanism: measured, it still passes + // with the explicit `cancel()` removed, because undici reclaims an unread + // response body here on its own. So the cancel is defensive — it makes + // the release explicit and independent of that behaviour — and this test + // will catch the route starting to hold the stream open, not the cancel + // being deleted. Said plainly rather than left to imply a stronger claim. + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stream-open` }), + }); + + expect(res.status).toBe(200); + const payload = (await res.json()) as { status: number; body?: string }; + expect(payload.status).toBe(200); + expect(payload.body).toBeUndefined(); + // The upstream sees its connection go away once the handler returns. + // Without the cancel this never resolves and the test times out. + await openStreamClosed; + }); + it("routes the outbound request through HTTP_PROXY (#2067)", async () => { // /api/fetch is the browser's ONLY way out to the network — the web // client's `environment.fetch` is `createRemoteFetch()`, which forwards diff --git a/core/auth/revocation.ts b/core/auth/revocation.ts index f2d16c0f..1ffc8e6b 100644 --- a/core/auth/revocation.ts +++ b/core/auth/revocation.ts @@ -268,14 +268,15 @@ export async function revokeToken( // outcome — and every caller has already cleared its local state by now, so // a rejection would break the documented best-effort guarantee. const { url, init } = buildRevocationRequest(params); - // The signal alone is not enough to bound this. In the browser the fetch is - // `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch` - // and does not forward `init.signal`; the backend's outbound fetch gets no - // signal either. So a wedged authorization server would hold the teardown - // open indefinitely on exactly the path the timeout exists for. The race is - // what actually enforces the deadline; the signal is kept because it does - // cancel the direct-fetch paths (CLI, TUI, backend) rather than merely - // abandoning them. + // The signal cancels; the race is what guarantees the deadline. Since #2319 + // the signal reaches every path — `createRemoteFetch` forwards it onto the + // `/api/fetch` hop and that route composes it into its outbound fetch, so a + // browser-side abort now tears down the backend's request to the + // authorization server as well as the direct-fetch paths (CLI, TUI, + // backend) it always did. The race is still not redundant: it bounds a + // `fetchFn` that ignores `AbortSignal` altogether, and a backend that is + // itself wedged. Same reasoning, and the same shape, as + // `withOAuthRequestTimeout`. const response = await withDeadline( params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs, @@ -307,11 +308,12 @@ export async function revokeToken( /** * Reject with a timeout error if `promise` has not settled within `timeoutMs`. * - * The underlying request is abandoned rather than cancelled — nothing here can - * cancel a fetch the proxy already stripped the signal from. That is the right - * trade for this caller: the point is that the *teardown* proceeds, and the - * response, if it ever arrives, is a revocation we no longer need to wait for. - * The timer is cleared on the settled path so a caller is never held awake by it. + * This abandons the promise; cancelling the request is the caller's signal's job + * (see `revokeToken`, where the two are used together). Abandonment alone is an + * acceptable floor for this caller even where the signal is ignored: the point + * is that the *teardown* proceeds, and the response, if it ever arrives, is a + * revocation we no longer need to wait for. The timer is cleared on the settled + * path so a caller is never held awake by it. */ async function withDeadline( promise: Promise, diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 071bcd49..42c20fbf 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1231,9 +1231,10 @@ export function createRemoteApp( const signal = clientSignal ? AbortSignal.any([controller.signal, clientSignal]) : controller.signal; - // Cleared in `finally`, before the handler returns. A streaming response is - // returned here without its body being read, so a timer left armed would - // sever a live stream 30 seconds in. + // Cleared in `finally`, before the handler returns. Safe because by then + // the body has been either read or explicitly cancelled — this route never + // hands a live stream to its caller, so there is nothing left for the timer + // to protect and nothing it could sever. const timer = setTimeout( () => controller.abort( @@ -1271,6 +1272,19 @@ export function createRemoteApp( let resBody: string | undefined; if (!isStream && res.body) { resBody = await res.text(); + } else if (isStream) { + // This route does not return the stream — it answers with JSON and no + // body — so nobody downstream owns it and nothing will ever read it. + // Left un-cancelled, an OAuth endpoint that sends event-stream or + // NDJSON headers and never closes would hold the upstream socket open + // for good, since the `finally` below clears the only deadline once + // this handler returns (Copilot). Best-effort, like `releaseBody` in + // `oidcDiscoveryCompat`: a body already consumed, locked or absent is + // not an error here. Measured, undici does reclaim an unread body on + // its own in this configuration — so this is belt and braces rather + // than a demonstrated leak, and it is kept because relying on that is + // an implementation detail of the fetch beneath us, not a contract. + await res.body?.cancel().catch(() => {}); } return c.json({ From baefc96806f3bbae6f641a90099c3e0c816c18d8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 04:16:15 -0400 Subject: [PATCH 016/120] fix: carry the proxy deadline's identity, correct the rounding note (#2319 round 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deadline enforced by the backend could not arrive as an OAuthRequestTimeoutError: /api/fetch serialized it as a 500 and createRemoteFetch turned that into a plain Error, so every instanceof check downstream was false — including the one that lets a stalled probe escape withRfc8414OidcCompat. That matters most where no client-side wrapper exists to have fired first: the discovery the SDK runs from inside the transport. The route now answers 504 with a typed marker (OAUTH_TIMEOUT_WIRE_CODE plus url and timeoutMs) and createRemoteFetch reconstructs the error from it, guarded by a shape check so an upstream's own JSON cannot forge one. The route's deadline becomes a `fetchTimeoutMs` option defaulting to the shared constant, so the backstop can be proven to fire in milliseconds instead of thirty seconds of wall clock — and because a very slow authorization server is a legitimate reason to want a longer one. The rounding rationale was wrong: setTimeout accepts a fractional delay and truncates it (verified on Node 26); ERR_OUT_OF_RANGE is AbortSignal.timeout's behaviour, which this wrapper never calls. The rounding stays, for the reason that is actually true — the budget is interpolated into the message and exposed as timeoutMs, and a performance.now() budget would otherwise report 1000.4000000953674ms. Also drops a sentence in the discarded-stream test that contradicted the qualification above it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 8 +- .../core/mcp/remote/createRemoteFetch.test.ts | 83 +++++++++++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 39 ++++++++- core/auth/index.ts | 3 + core/auth/requestTimeout.ts | 54 ++++++++++-- core/mcp/remote/createRemoteFetch.ts | 21 +++++ core/mcp/remote/node/server.ts | 59 +++++++++++-- 7 files changed, 247 insertions(+), 20 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 5308437d..6ff65333 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -318,10 +318,12 @@ describe("withOAuthRequestTimeout", () => { await expect(wrapped(URL_UNDER_TEST)).rejects.toBe(boom); }); - it("rounds a fractional budget, because setTimeout takes an integer", async () => { + it("rounds a fractional budget, which is reported to the caller", async () => { vi.useFakeTimers(); - // A budget derived from `performance.now()` arrives fractional; a fractional - // delay throws ERR_OUT_OF_RANGE before the request is ever made. + // `setTimeout` would accept the fraction and truncate it. The rounding is + // for the budget's *reported* form: a `performance.now()` subtraction would + // otherwise name "1000.4000000953674ms" in the message and expose a + // non-integer `timeoutMs`. const wrapped = withOAuthRequestTimeout(neverSettles, 1000.4); const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index ea0d7f66..12de59ce 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js"; +import { + OAUTH_TIMEOUT_WIRE_CODE, + OAuthRequestTimeoutError, +} from "@inspector/core/auth/requestTimeout.js"; function ok(body: object): Response { return new Response(JSON.stringify(body), { @@ -241,4 +245,83 @@ describe("createRemoteFetch", () => { expect(signalOf(fetchFn)).toBeUndefined(); }); }); + + describe("proxy-side deadline (#2319)", () => { + function remoteReturning(status: number, body: string) { + const fetchFn = vi.fn().mockResolvedValue( + new Response(body, { + status, + headers: { "content-type": "application/json" }, + }), + ); + return createRemoteFetch({ + baseUrl: "http://remote.example", + fetchFn: fetchFn as unknown as typeof fetch, + }); + } + + it("rebuilds an OAuthRequestTimeoutError from the route's marker", async () => { + // The one path where a timeout reaches the browser without a client-side + // wrapper having fired first: the discovery the SDK runs from inside the + // transport has no wrapper at all, so the backend's deadline is the only + // one, and it arrives as an ordinary error response. + const remoteFetch = remoteReturning( + 504, + JSON.stringify({ + error: "proxied request to https://as.example.com/x timed out", + code: OAUTH_TIMEOUT_WIRE_CODE, + url: "https://as.example.com/x", + timeoutMs: 30000, + }), + ); + + const err = await remoteFetch("https://as.example.com/x").catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(OAuthRequestTimeoutError); + const timeout = err as OAuthRequestTimeoutError; + expect(timeout.url).toBe("https://as.example.com/x"); + expect(timeout.timeoutMs).toBe(30000); + }); + + it("leaves an ordinary error response as a plain Error", async () => { + const remoteFetch = remoteReturning( + 500, + JSON.stringify({ error: "connect ECONNREFUSED" }), + ); + + const err = await remoteFetch("https://as.example.com/x").catch( + (e: unknown) => e, + ); + + expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError); + expect((err as Error).message).toMatch(/Remote fetch failed \(500\)/); + }); + + it("ignores a non-JSON error body rather than throwing on the parse", async () => { + const remoteFetch = remoteReturning(502, "bad gateway"); + + const err = await remoteFetch("https://as.example.com/x").catch( + (e: unknown) => e, + ); + + expect((err as Error).message).toMatch(/Remote fetch failed \(502\)/); + }); + + it("ignores a marker whose fields are the wrong shape", async () => { + // An upstream could serve JSON of its own through a failing proxy; the + // guard checks the field types, not just the code. + const remoteFetch = remoteReturning( + 504, + JSON.stringify({ code: OAUTH_TIMEOUT_WIRE_CODE, url: 5 }), + ); + + const err = await remoteFetch("https://as.example.com/x").catch( + (e: unknown) => e, + ); + + expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 648da1c9..9613d2af 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -51,6 +51,7 @@ interface StartOpts { secretStore?: SecretStore; logger?: pinoType.Logger; seedConfig?: string; + fetchTimeoutMs?: number; } async function start(opts: StartOpts = {}): Promise { @@ -65,6 +66,9 @@ async function start(opts: StartOpts = {}): Promise { initialConfig: { defaultEnvironment: {} }, secretStore: opts.secretStore ?? new InMemorySecretStore(), logger: opts.logger, + ...(opts.fetchTimeoutMs !== undefined && { + fetchTimeoutMs: opts.fetchTimeoutMs, + }), }); const { baseUrl, server } = await new Promise<{ baseUrl: string; @@ -465,11 +469,42 @@ describe("server.ts supplemental coverage", () => { const payload = (await res.json()) as { status: number; body?: string }; expect(payload.status).toBe(200); expect(payload.body).toBeUndefined(); - // The upstream sees its connection go away once the handler returns. - // Without the cancel this never resolves and the test times out. + // The observable contract: the route does not leave the upstream stream + // running after it has answered. (Per the qualification above, this does + // not isolate the explicit `cancel()` as the cause.) await openStreamClosed; }); + it("answers 504 with a typed marker when its own deadline fires (#2319)", async () => { + // The backstop, on its own short budget so this costs milliseconds rather + // than the production thirty seconds. It matters because this is the only + // deadline on the transport-internal discovery path, which has no + // client-side wrapper — so its error has to survive the hop as something + // an `instanceof OAuthRequestTimeoutError` check can still recognize. + await stop(h); + h = await start({ fetchTimeoutMs: 120 }); + + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stall` }), + }); + + expect(res.status).toBe(504); + const payload = (await res.json()) as { + error: string; + code: string; + url: string; + timeoutMs: number; + }; + expect(payload.code).toBe("oauth_request_timeout"); + expect(payload.url).toBe(`${targetUrl}/stall`); + expect(payload.timeoutMs).toBe(120); + expect(payload.error).toContain("timed out after 120ms"); + // And the upstream is released rather than left running. + await stallClosed; + }); + it("routes the outbound request through HTTP_PROXY (#2067)", async () => { // /api/fetch is the browser's ONLY way out to the network — the web // client's `environment.fetch` is `createRemoteFetch()`, which forwards diff --git a/core/auth/index.ts b/core/auth/index.ts index 6dd49b3b..4cfde438 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -54,9 +54,12 @@ export { // OAuth-path request deadline (#2319) export { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + OAUTH_TIMEOUT_WIRE_CODE, OAuthRequestTimeoutError, + isOAuthRequestTimeoutWire, withOAuthRequestTimeout, } from "./requestTimeout.js"; +export type { OAuthRequestTimeoutWire } from "./requestTimeout.js"; // Storage export type { diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 7c2dadf0..65372ddc 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -83,6 +83,40 @@ export class OAuthRequestTimeoutError extends Error { } } +/** + * How a proxy-side deadline is carried back to the browser (#2319). + * + * `/api/fetch` serializes its failures as a JSON error response, and + * `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a + * timeout enforced by the *backend* would arrive as an untyped error and the + * `instanceof OAuthRequestTimeoutError` checks downstream would all be false + * (Copilot). That matters most exactly where there is no client-side wrapper to + * have fired first: the discovery the SDK runs from inside the transport. So + * the route stamps this marker and `createRemoteFetch` reconstructs the typed + * error from it. + */ +export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout"; + +/** The body `/api/fetch` returns when its own deadline fired. */ +export interface OAuthRequestTimeoutWire { + code: typeof OAUTH_TIMEOUT_WIRE_CODE; + url: string; + timeoutMs: number; +} + +/** Whether a parsed `/api/fetch` error body is a proxy-side deadline. */ +export function isOAuthRequestTimeoutWire( + value: unknown, +): value is OAuthRequestTimeoutWire { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + candidate.code === OAUTH_TIMEOUT_WIRE_CODE && + typeof candidate.url === "string" && + typeof candidate.timeoutMs === "number" + ); +} + /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; @@ -187,12 +221,20 @@ export function withOAuthRequestTimeout( fetchFn: typeof fetch, timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, ): typeof fetch { - // Whole milliseconds, because `setTimeout` and `AbortSignal.timeout` both - // take an integer: Node throws `ERR_OUT_OF_RANGE` on a fractional delay — - // before the fetch, so the request is never sent and the caller sees a failed - // operation rather than a timeout. A budget derived from `performance.now()` - // is fractional, so this is reachable. `revocation.ts:253` carries the same - // note for the same reason. + // Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one + // calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on + // a fractional delay (verified on Node 26) and so would fail before the + // request was sent. This wrapper drives its deadline with `setTimeout` and an + // `AbortController`, and `setTimeout` accepts a fractional delay and + // truncates it, so nothing here would throw (Copilot). + // + // The real reason is that the budget is *reported*: it is interpolated into + // the timeout message and exposed as `OAuthRequestTimeoutError.timeoutMs`. A + // caller whose budget came from a `performance.now()` subtraction would + // otherwise produce "timed out after 1000.4000000953674ms" and a non-integer + // public field. Rounding rather than flooring so a caller's own + // whole-millisecond timeout survives the trip through that clock and is still + // the number the message names. const budget = Math.max(0, Math.round(timeoutMs)); return async (input, init) => { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 36bbba2d..bd54db8c 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -21,6 +21,11 @@ * the web client against a strict modern server, same as from the CLI/TUI. */ +import { + isOAuthRequestTimeoutWire, + OAuthRequestTimeoutError, +} from "../../auth/requestTimeout.js"; + export interface RemoteFetchOptions { /** Base URL of the remote server (e.g. http://localhost:3000) */ baseUrl: string; @@ -158,6 +163,22 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { if (!res.ok) { const text = await res.text(); + // A deadline the backend enforced arrives as an ordinary error response; + // rebuild the typed error from its marker so the `instanceof` checks + // downstream — most importantly the one that lets a stalled probe escape + // `withRfc8414OidcCompat` — still see a timeout for what it is (#2319, + // Copilot). This is the only path on which such a timeout can reach the + // browser without a client-side wrapper having fired first: the discovery + // the SDK runs from inside the transport has no wrapper at all. + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (isOAuthRequestTimeoutWire(parsed)) { + throw new OAuthRequestTimeoutError(parsed.url, parsed.timeoutMs); + } throw new Error(`Remote fetch failed (${res.status}): ${text}`); } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 42c20fbf..3e8a121b 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -28,7 +28,10 @@ import { bodyLimit } from "hono/body-limit"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; import { createProxyFetch } from "../../node/proxyFetch.js"; -import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS } from "../../../auth/requestTimeout.js"; +import { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + OAUTH_TIMEOUT_WIRE_CODE, +} from "../../../auth/requestTimeout.js"; import type { RemoteConnectRequest, RemoteSendRequest, @@ -235,6 +238,21 @@ export interface RemoteServerOptions { /** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */ mcpConfigPath?: string; + /** + * Deadline for the outbound request `/api/fetch` makes, in milliseconds + * (#2319). Defaults to {@link DEFAULT_OAUTH_REQUEST_TIMEOUT_MS}, the same + * budget the browser-side `withOAuthRequestTimeout` uses, so the two agree on + * when an attempt is hopeless. + * + * This is the backstop for a client that vanishes without aborting; the + * primary mechanism is the caller's own signal, which `createRemoteFetch` + * forwards onto this hop. Exposed rather than hardcoded so a test can prove + * the backstop actually fires without spending thirty seconds of wall clock + * on it — and because a very slow authorization server is a legitimate reason + * to want a longer one. + */ + fetchTimeoutMs?: number; + /** * When false, the server list is read-only for this session: all * `/api/servers` mutations (POST/PUT/DELETE/order) are rejected with 403, @@ -570,6 +588,10 @@ export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth; + const fetchTimeoutMs = Math.max( + 0, + Math.round(options.fetchTimeoutMs ?? DEFAULT_OAUTH_REQUEST_TIMEOUT_MS), + ); // Determine auth token when auth is enabled: options > env var > generate const authToken = dangerouslyOmitAuth @@ -1227,6 +1249,11 @@ export function createRemoteApp( // client that vanishes without aborting, and it matches the client-side // budget so the two agree on when this is hopeless. const controller = new AbortController(); + // Set by the timer below and read in the `catch`. The fetch rejects with + // the signal's abort reason, but which of several aborts won is not worth + // inferring from the error — the flag says it directly, the same + // normalization `withOAuthRequestTimeout` uses. + let deadlineFired = false; const clientSignal: AbortSignal | undefined = c.req.raw.signal; const signal = clientSignal ? AbortSignal.any([controller.signal, clientSignal]) @@ -1235,15 +1262,14 @@ export function createRemoteApp( // the body has been either read or explicitly cancelled — this route never // hands a live stream to its caller, so there is nothing left for the timer // to protect and nothing it could sever. - const timer = setTimeout( - () => - controller.abort( - new Error( - `proxied request to ${url} timed out after ${DEFAULT_OAUTH_REQUEST_TIMEOUT_MS}ms`, - ), + const timer = setTimeout(() => { + deadlineFired = true; + controller.abort( + new Error( + `proxied request to ${url} timed out after ${fetchTimeoutMs}ms`, ), - DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, - ); + ); + }, fetchTimeoutMs); try { // Proxy-aware, not the bare global. This route is the browser's ONLY way @@ -1296,6 +1322,21 @@ export function createRemoteApp( }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); + if (deadlineFired) { + // Stamped so the browser can rebuild an `OAuthRequestTimeoutError` + // rather than receiving a plain `Error` that no `instanceof` check + // downstream can recognize (Copilot). 504 because that is what this + // is — the gateway gave up on the upstream, not a fault in the route. + return c.json( + { + error: msg, + code: OAUTH_TIMEOUT_WIRE_CODE, + url, + timeoutMs: fetchTimeoutMs, + }, + 504, + ); + } return c.json({ error: msg }, 500); } finally { clearTimeout(timer); From 19718c540279b4cb2c2450eb4ac1f0434cfc63e4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 04:31:36 -0400 Subject: [PATCH 017/120] chore: file header and forwarding mocks on the new tests (#2319 round 10) requestTimeout.test.ts had no file-level header, which AGENTS.md requires of every file; it now explains what each group of cases pins and why, including why the composition cases are written as a contrast. The two mock fetches I added used `as unknown as typeof fetch`, which AGENTS.md rules out without an inline justification. A contextually typed forwarding function keeps the vitest mock inspectable and needs no cast at all. The PR description still described the pre-fix cancellation flow; it is rewritten to say that the signal now reaches the backend's outbound fetch and what the race is still for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 23 +++++++++++++++++++ .../core/mcp/remote/createRemoteFetch.test.ts | 7 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 6ff65333..0bf8dfa0 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -1,3 +1,26 @@ +/** + * Unit coverage for `withOAuthRequestTimeout` (#2319) — the deadline every + * OAuth-path request runs under. + * + * What is pinned here, and why each case exists rather than being obvious: + * + * - **The bound itself**, including the *body*: `fetch` resolves on headers, so + * a server that sends them and then stalls has to be caught by the buffering + * race, not by the fetch promise. + * - **The rebuilt response.** Buffering means the caller gets a different + * `Response` object, so `url` / `redirected` / `type` are asserted to survive + * and `content-encoding` / `content-length` to be dropped — the buffer holds + * the decoded body, which makes both of those headers lies. + * - **Cancellation semantics.** The caller's signal is forwarded *and* raced, + * so the tests distinguish the two: one uses a fetch that honours the signal, + * another uses one that ignores it entirely. + * - **Composition with `withRfc8414OidcCompat`**, written as a contrast between + * the two orderings. It is the ordering that is under test, not one + * arrangement's behaviour, so the wrong one is asserted to misattribute. + * + * Fake timers throughout: every budget here is exercised by advancing the clock + * rather than by waiting, so the suite costs milliseconds. + */ import { describe, it, expect, vi, afterEach } from "vitest"; import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index 12de59ce..f5be8fb0 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -183,7 +183,9 @@ describe("createRemoteFetch", () => { .mockResolvedValue(ok(standardRemoteBody)); const remoteFetch = createRemoteFetch({ baseUrl: "http://remote.example", - fetchFn: fetchFn as unknown as typeof fetch, + // A contextually typed forwarding function rather than a double cast: + // the mock stays inspectable and nothing bypasses the type system. + fetchFn: (input, init) => fetchFn(input, init), }); return { fetchFn, remoteFetch }; } @@ -256,7 +258,8 @@ describe("createRemoteFetch", () => { ); return createRemoteFetch({ baseUrl: "http://remote.example", - fetchFn: fetchFn as unknown as typeof fetch, + // Forwarding function, not a double cast — see `capture` above. + fetchFn: (input, init) => fetchFn(input, init), }); } From f491c7fc9a170590e41b17007b1d9f5fd94b161b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 04:49:05 -0400 Subject: [PATCH 018/120] feat: bound SDK-internal OAuth on the transport chain too (#2319 round 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport chain carried the SDK's own OAuth work — the discovery it runs from inside the transport on the 401/refresh path — and that leg was left to the SDK's incidental per-request timeout, reporting a bare `Request timed out` naming no endpoint. On a direct (non-proxied) path the /api/fetch backstop cannot reach it either. `withOAuthRequestTimeout` takes an optional `isExempt` predicate; an exempt request passes through completely untouched — no deadline, no signal, and no body buffering, which is what a stream needs. `exemptMcpEndpoint` exempts the MCP endpoint by origin + pathname, and FAILS OPEN: no server URL, or either side unparseable, means exempt. The errors are not symmetric. Bounding what should not be bounded severs a long-running tool call withholding its headers, or an SSE body; failing to bound leaves the SDK's timeout as the backstop it already was. Two test corrections, both real: - The metadata-preservation case asserted url/redirected/type against a synthetic Response, whose values are the rebuild's own defaults — so it stayed green with the defineProperty loop deleted. It now seeds non-default values first; verified by mutation that removing the loop makes it fail. - Two signal assertions compared against the controller rather than `request.signal`. The Fetch standard gives a Request a dependent signal, so those encoded non-standard identity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 99 ++++++++++++++++++- .../core/mcp/remote/createRemoteFetch.test.ts | 25 +++-- core/auth/index.ts | 1 + core/auth/requestTimeout.ts | 48 +++++++++ core/mcp/inspectorClient.ts | 22 ++++- 5 files changed, 181 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 0bf8dfa0..8c8be1cd 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, OAuthRequestTimeoutError, + exemptMcpEndpoint, withOAuthRequestTimeout, } from "@inspector/core/auth/requestTimeout.js"; import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js"; @@ -270,6 +271,24 @@ describe("withOAuthRequestTimeout", () => { statusText: "Created", headers: { "content-type": "application/json", "x-probe": "kept" }, }); + // Seeded to NON-default values, which is the whole point of the case: a + // synthetic `new Response(...)` already has `url === ""`, `redirected === + // false` and `type === "default"` — exactly the rebuilt response's own + // defaults — so asserting against those would stay green with the + // `Object.defineProperty` loop deleted from `rebuildResponse` (Copilot). + // These are read-only getters, hence the same mechanism the source uses. + Object.defineProperty(original, "url", { + value: "https://as.example.com/redirected", + configurable: true, + }); + Object.defineProperty(original, "redirected", { + value: true, + configurable: true, + }); + Object.defineProperty(original, "type", { + value: "cors", + configurable: true, + }); const wrapped = withOAuthRequestTimeout( vi.fn().mockResolvedValue(original), 1000, @@ -283,9 +302,9 @@ describe("withOAuthRequestTimeout", () => { expect(response.headers.get("x-probe")).toBe("kept"); expect(response.headers.get("content-encoding")).toBeNull(); expect(response.headers.get("content-length")).toBeNull(); - expect(response.url).toBe(original.url); - expect(response.redirected).toBe(original.redirected); - expect(response.type).toBe(original.type); + expect(response.url).toBe("https://as.example.com/redirected"); + expect(response.redirected).toBe(true); + expect(response.type).toBe("cors"); await expect(response.json()).resolves.toEqual({ issuer: "https://as.example.com", }); @@ -385,6 +404,80 @@ describe("withOAuthRequestTimeout", () => { }); }); + describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => { + const SERVER = "https://srv.example.com/mcp"; + + it("exempts the MCP endpoint itself, whatever method or query", () => { + const isExempt = exemptMcpEndpoint(() => SERVER); + + expect(isExempt(SERVER)).toBe(true); + expect(isExempt(`${SERVER}?sessionId=abc`)).toBe(true); + expect(isExempt(`${SERVER}#frag`)).toBe(true); + }); + + it("bounds OAuth work on the same chain", () => { + const isExempt = exemptMcpEndpoint(() => SERVER); + + expect( + isExempt( + "https://srv.example.com/.well-known/oauth-protected-resource/mcp", + ), + ).toBe(false); + expect(isExempt("https://as.example.com/token")).toBe(false); + // Same origin, different path — still not the endpoint. + expect(isExempt("https://srv.example.com/mcp/register")).toBe(false); + }); + + it("fails open when the server URL is unknown or unparseable", () => { + // The two errors are not symmetric: bounding what should not be bounded + // severs a long-running tool call, while failing to bound leaves the + // SDK's own per-request timeout as the backstop it already was. + expect( + exemptMcpEndpoint(() => undefined)("https://as.example.com/token"), + ).toBe(true); + expect(exemptMcpEndpoint(() => "")("https://as.example.com/token")).toBe( + true, + ); + expect(exemptMcpEndpoint(() => "not a url")(SERVER)).toBe(true); + expect(exemptMcpEndpoint(() => SERVER)("not a url")).toBe(true); + }); + + it("reads the server URL per call, since it changes between connects", () => { + let current = SERVER; + const isExempt = exemptMcpEndpoint(() => current); + + expect(isExempt("https://other.example.com/mcp")).toBe(false); + current = "https://other.example.com/mcp"; + expect(isExempt("https://other.example.com/mcp")).toBe(true); + }); + }); + + it("passes an exempt request through with no deadline and no buffering", async () => { + vi.useFakeTimers(); + // Untouched means untouched: no signal of ours, and the body is left as a + // live stream rather than buffered — which is what an SSE response needs. + const body = new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode("data: hi\n\n")); + }, + }); + const original = new Response(body, { + headers: { "content-type": "text/event-stream" }, + }); + const inner = vi.fn().mockResolvedValue(original); + const wrapped = withOAuthRequestTimeout(inner, 1000, () => true); + + const response = await wrapped(URL_UNDER_TEST); + + expect(response).toBe(original); + expect( + (inner.mock.calls[0][1] as RequestInit | undefined)?.signal, + ).toBeUndefined(); + // Nothing is armed, so advancing past the budget cannot sever it. + await vi.advanceTimersByTimeAsync(5000); + expect(response.bodyUsed).toBe(false); + }); + describe("composed under withRfc8414OidcCompat (#2319 ordering)", () => { // `withRfc8414OidcCompat` re-fetches an OIDC candidate after a failed RFC // 8414 discovery, so where the deadline sits relative to it decides which diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index f5be8fb0..0eaa397b 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -206,25 +206,32 @@ describe("createRemoteFetch", () => { it("forwards a Request's own signal when init has none", async () => { const { fetchFn, remoteFetch } = capture(); const caller = new AbortController(); + // Compared against `request.signal`, not `caller.signal`: the Fetch + // standard gives `new Request(url, { signal })` a *dependent* signal, so + // the two are distinct objects in a spec-compliant runtime. Asserting + // against the controller's would encode non-standard identity and could + // pass or fail on the host rather than on the behaviour (Copilot). + const request = new Request("http://upstream.example/", { + signal: caller.signal, + }); - await remoteFetch( - new Request("http://upstream.example/", { signal: caller.signal }), - ); + await remoteFetch(request); - expect(signalOf(fetchFn)).toBe(caller.signal); + expect(signalOf(fetchFn)).toBe(request.signal); }); it("treats an undefined init.signal as absent, keeping the Request's", async () => { // WebIDL dictionary conversion: a member present as `undefined` is absent. const { fetchFn, remoteFetch } = capture(); const caller = new AbortController(); + const request = new Request("http://upstream.example/", { + signal: caller.signal, + }); - await remoteFetch( - new Request("http://upstream.example/", { signal: caller.signal }), - { signal: undefined }, - ); + await remoteFetch(request, { signal: undefined }); - expect(signalOf(fetchFn)).toBe(caller.signal); + // Against `request.signal` for the dependent-signal reason above. + expect(signalOf(fetchFn)).toBe(request.signal); }); it("honours an explicit null init.signal as no signal", async () => { diff --git a/core/auth/index.ts b/core/auth/index.ts index 4cfde438..e38812eb 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -56,6 +56,7 @@ export { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, OAUTH_TIMEOUT_WIRE_CODE, OAuthRequestTimeoutError, + exemptMcpEndpoint, isOAuthRequestTimeoutWire, withOAuthRequestTimeout, } from "./requestTimeout.js"; diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 65372ddc..d58d06e9 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -117,6 +117,37 @@ export function isOAuthRequestTimeoutWire( ); } +/** + * Build the `isExempt` predicate for a chain that carries MCP traffic as well as + * OAuth work: it returns true for the MCP endpoint itself. + * + * ⚠️ **Fails open, deliberately.** The two errors are not symmetric. Bounding a + * request that should not be bounded severs a long-running tool call or an SSE + * stream — a severe, user-visible regression. Failing to bound one leaves the + * SDK's own per-request timeout as the backstop it has always been. So anything + * uncertain — no server URL yet, a URL that will not parse, either side — is + * treated as the MCP endpoint and exempted. + * + * Compared on origin + pathname: the SDK varies method and query across its MCP + * requests but not the endpoint, while OAuth discovery goes to a different path + * (`/.well-known/…`) and usually a different origin entirely. + */ +export function exemptMcpEndpoint( + getServerUrl: () => string | undefined, +): (url: string) => boolean { + return (url) => { + const serverUrl = getServerUrl(); + if (!serverUrl) return true; + try { + const a = new URL(url); + const b = new URL(serverUrl); + return a.origin === b.origin && a.pathname === b.pathname; + } catch { + return true; + } + }; +} + /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; @@ -208,6 +239,17 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { * `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the * transport is handed directly. * + * `isExempt` lets one wrapped fetch serve a mixed chain. It is how the + * *transport* fetch can be bounded at all: that chain carries both the SDK's + * OAuth work and the MCP traffic itself, and the MCP traffic must not be + * timed — a Streamable HTTP POST for a long-running tool call legitimately + * withholds its response headers for minutes, and an SSE body stays open + * indefinitely. So `InspectorClient` exempts the MCP endpoint by URL and bounds + * everything else on that chain, and **fails open**: if the server URL cannot + * be determined or parsed, everything is exempt. Bounding a request that should + * not be bounded breaks a working tool call; failing to bound one leaves the + * SDK's own per-request timeout as the backstop it already was. + * * A caller's own signal is preserved — whether it arrived in `init` or embedded * in a `Request` — and is both forwarded to the inner fetch (composed with * `AbortSignal.any`) and raced here, so an outer cancellation still wins even on @@ -220,6 +262,7 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { export function withOAuthRequestTimeout( fetchFn: typeof fetch, timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + isExempt?: (url: string) => boolean, ): typeof fetch { // Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one // calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on @@ -239,6 +282,11 @@ export function withOAuthRequestTimeout( return async (input, init) => { const url = requestUrlOf(input); + // An exempt request is passed through completely untouched — no deadline, + // no signal of ours, and crucially no body buffering, since the responses + // this exists for are streams that must stay open (see `isExempt` above). + if (isExempt?.(url)) return fetchFn(input, init); + const controller = new AbortController(); const callerSignal = callerSignalOf(input, init); // Before anything is constructed or sent. Racing an already-aborted signal diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 7ccbefa3..31d6b311 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -256,7 +256,11 @@ import { } from "../auth/challenge.js"; import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js"; -import { withOAuthRequestTimeout } from "../auth/requestTimeout.js"; +import { + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + exemptMcpEndpoint, + withOAuthRequestTimeout, +} from "../auth/requestTimeout.js"; import type { TokenRevocationOutcome } from "../auth/revocation.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; @@ -824,7 +828,21 @@ export class InspectorClient extends InspectorClientEventTarget { // still hit the upstream failure. The substituted response is stamped with // `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came // from rather than appearing to be a 200 from the RFC 8414 path. - this.fetchFn = withRfc8414OidcCompat(withOverrides); + // The transport chain is bounded too, but only for the requests on it that + // are *not* MCP traffic — the OAuth work the SDK runs from inside the + // transport on the 401/refresh path, which otherwise waits out the SDK's + // incidental per-request timeout and reports a bare `Request timed out` + // naming no endpoint (Copilot). The MCP endpoint itself is exempt, and the + // predicate fails open: a long-running tool call withholding its response + // headers for minutes, or an SSE body that stays open, must never be timed + // here. Deadline innermost, for the same reason as the auth chain below. + this.fetchFn = withRfc8414OidcCompat( + withOAuthRequestTimeout( + withOverrides, + DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + exemptMcpEndpoint(() => this.getServerUrl()), + ), + ); // #2319: the auth chain is composed separately so the deadline sits // *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put // it outside, and then a stalled OIDC probe would be reported under the From 234d0a9ac43a35c43178867e60ec82c0ee6039ba Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 05:07:31 -0400 Subject: [PATCH 019/120] fix: preserve Response enumerability, refresh transport docs (#2319 round 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying url/redirected/type onto the rebuilt response as enumerable own properties changed what a consumer sees: `Object.keys`, object spread and `JSON.stringify` are all affected. Which answer is correct turns out to be the host's business — under the Fetch standard these are prototype getters with no own enumerable key, while happy-dom defines them as own enumerable data properties — so the descriptor is copied from the freshly built Response rather than either answer being hardcoded. The test compares against a baseline built in the same runtime for the same reason. Three comments still described the transport fetch as unbounded, which round 11 changed: - the wrapper's own docblock said it must never wrap the transport, obscuring that isExempt is what makes doing so safe; - the auth-chain comment in inspectorClient repeated it; - buildEffectiveAuthFetch's docblock said transport-internal discovery is not covered by a deadline. All three now state the real distinction — both chains are bounded, and they differ only in that the transport chain exempts the MCP endpoint — and the docblock says why reusing the transport chain here would be wrong rather than merely different. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 10 +++++++ core/auth/requestTimeout.ts | 27 ++++++++++++++----- core/mcp/inspectorClient.ts | 22 ++++++++++----- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 8c8be1cd..0738add7 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -305,6 +305,16 @@ describe("withOAuthRequestTimeout", () => { expect(response.url).toBe("https://as.example.com/redirected"); expect(response.redirected).toBe(true); expect(response.type).toBe("cors"); + // The rebuild must not change what enumerates. Which keys a `Response` owns + // is the host's business — under the Fetch standard these three are + // prototype getters and a native response has no own enumerable keys, while + // happy-dom makes them own enumerable data properties — so this compares + // against a baseline built in the same runtime rather than asserting either + // answer. Hard-coding one would make the rebuilt response observably + // different from a response that never passed through the wrapper, in + // `Object.keys`, object spread and `JSON.stringify`. + const baseline = Object.keys(new Response("{}")).sort(); + expect(Object.keys(response).sort()).toEqual(baseline); await expect(response.json()).resolves.toEqual({ issuer: "https://as.example.com", }); diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index d58d06e9..1f1df46c 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -220,9 +220,19 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { }, ); for (const key of ["url", "redirected", "type"] as const) { + // Enumerability is taken from whatever the freshly built `Response` already + // has for this key, rather than asserted. Hard-coding either answer makes + // the rebuilt response observably different from a native one — and which + // answer is right is the host's business: under the Fetch standard (and + // undici) these are prototype getters with no own enumerable key at all, + // while happy-dom defines them as own enumerable data properties. Copying + // the descriptor keeps `Object.keys`, object spread and `JSON.stringify` + // identical to a response that never passed through here, in both + // (Copilot). `false` for the getter case, where there is no own descriptor. + const existing = Object.getOwnPropertyDescriptor(rebuilt, key); Object.defineProperty(rebuilt, key, { value: response[key], - enumerable: true, + enumerable: existing?.enumerable ?? false, configurable: true, }); } @@ -232,12 +242,15 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { /** * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. * - * Apply this to an **OAuth-path** fetch only. It must never wrap the transport - * fetch: a Streamable HTTP or SSE response is a long-lived stream that is - * *supposed* to stay open, and a deadline there would sever every connection - * after the budget. `InspectorClient` keeps the two apart by wrapping inside - * `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the - * transport is handed directly. + * Every request that reaches it unexempted is bounded — so on a chain that + * carries anything other than OAuth work, `isExempt` is not optional. A + * Streamable HTTP or SSE response is a long-lived stream that is *supposed* to + * stay open, and a Streamable HTTP POST for a long-running tool call + * legitimately withholds its response headers for minutes; a deadline over + * either severs a working connection. `InspectorClient` uses the wrapper on + * both of its chains and the two differ only in this: the OAuth chain passes no + * predicate because every request on it is OAuth work, while the transport + * chain passes `exemptMcpEndpoint`. * * `isExempt` lets one wrapped fetch serve a mixed chain. It is how the * *transport* fetch can be bounded at all: that chain carries both the SDK's diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 31d6b311..94abbc87 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -849,8 +849,9 @@ export class InspectorClient extends InspectorClientEventTarget { // URL of the RFC 8414 request that preceded it — defeating the whole point // of naming the endpoint — while every probe in the loop shared that one // request's budget (Copilot). Innermost here matches the CLI's - // `storedAuthFetch`. The transport keeps `this.fetchFn`, deliberately - // unbounded: its bodies are streams that are supposed to stay open. + // `storedAuthFetch`. It also passes no `isExempt`, unlike the transport + // chain above: every request on this chain is OAuth work, so there is + // nothing here to exempt. this.effectiveAuthFetch = this.buildEffectiveAuthFetch( withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)), ); @@ -1022,11 +1023,18 @@ export class InspectorClient extends InspectorClientEventTarget { /** * @param base - the composed OAuth-path fetch, deadline innermost (#2319). - * Passed in rather than read off `this.fetchFn`, which is the *transport* - * chain and must stay unbounded. The cost of keeping the two apart is that - * the discovery the SDK runs from inside the transport (the 401/refresh path) - * is not covered by the deadline; that leg sits inside an SDK request and is - * bounded by its per-request timeout. + * + * Passed in rather than read off `this.fetchFn` because the two chains are + * bounded on different terms, not because one of them is unbounded. Both + * carry a deadline; the transport chain additionally exempts the MCP endpoint + * (`exemptMcpEndpoint`), since its bodies are streams meant to stay open and + * a long-running tool call may withhold its headers for minutes. Reusing it + * here would extend that exemption to the OAuth manager's own requests — and + * an authorization server published at the MCP endpoint's path would then go + * unbounded on the one chain that exists to bound it. + * + * The SDK's transport-internal discovery (the 401/refresh path) is covered by + * the transport chain's own deadline, not by this one. */ private buildEffectiveAuthFetch(base: typeof fetch): typeof fetch { // Capture auth response bodies (OAuth discovery, DCR, token exchange) so From de6960ec5a6ce3cf4620fd83082f1a4d47fbba6d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 05:24:24 -0400 Subject: [PATCH 020/120] fix: make the proxy deadline per-request, not unconditional (#2319 round 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side timer added in round 9 applied to every /api/fetch request. But that route carries MCP traffic as well as OAuth work, so a Streamable HTTP tool call that legitimately withholds its response headers past the budget was aborted there — reintroducing on the backend exactly the regression exemptMcpEndpoint prevents on the client, and reporting it as an OAuth timeout besides. The caller now decides. `withOAuthRequestTimeout` stamps its budget on the RequestInit it hands down, `createRemoteFetch` reads it back and puts it in the /api/fetch envelope, and the route applies a timer only when one is present — so an exempt MCP request, which never gets an init of its own, is bounded by nothing. The carrier is a WeakMap keyed on the init rather than a header: serializeRequest copies request headers verbatim into the payload and the route re-sends them to the authorization server, so a marker header would leak to a third party. It needs no cast onto RequestInit and is collected with the object. The `fetchTimeoutMs` option from round 9 is gone — the deadline now lives in exactly one place, the client wrapper, which is also what makes the 504 marker honest: only a request the OAuth wrapper bounded can produce one. Ten tests: the stamping and its rounding, nothing stamped on an exempt call or an init that never went through the wrapper, the envelope carrying the budget (and not a header), its omission on both exempt and direct calls, and at the route the 504 marker, no deadline without one, and a non-numeric deadline ignored. The no-deadline case is verified by mutation: an unconditional timer makes it fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 41 ++++++++++ .../core/mcp/remote/createRemoteFetch.test.ts | 58 ++++++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 80 +++++++++++++++---- core/auth/index.ts | 1 + core/auth/requestTimeout.ts | 40 +++++++++- core/mcp/remote/createRemoteFetch.ts | 19 ++++- core/mcp/remote/node/server.ts | 70 ++++++++-------- 7 files changed, 259 insertions(+), 50 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 0738add7..dbd89bcc 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, OAuthRequestTimeoutError, + deadlineForRequestInit, exemptMcpEndpoint, withOAuthRequestTimeout, } from "@inspector/core/auth/requestTimeout.js"; @@ -414,6 +415,46 @@ describe("withOAuthRequestTimeout", () => { }); }); + describe("the deadline stamped on the init (#2319 proxy hop)", () => { + // How the budget reaches `/api/fetch` without travelling upstream: a header + // would be copied verbatim into the payload and re-sent to the + // authorization server, so the carrier is a WeakMap keyed on the init. + it("stamps the budget on the init it hands down", async () => { + const inner = vi.fn().mockResolvedValue(new Response("{}")); + const wrapped = withOAuthRequestTimeout(inner, 1234); + + await wrapped(URL_UNDER_TEST); + + expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1234); + }); + + it("stamps the rounded budget, matching what is reported", async () => { + const inner = vi.fn().mockResolvedValue(new Response("{}")); + const wrapped = withOAuthRequestTimeout(inner, 1000.4); + + await wrapped(URL_UNDER_TEST); + + expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1000); + }); + + it("stamps nothing on an exempt request", async () => { + // The route must apply no deadline to MCP traffic, and it decides that + // from the absence of a budget in the envelope. + const inner = vi.fn().mockResolvedValue(new Response("{}")); + const wrapped = withOAuthRequestTimeout(inner, 1234, () => true); + + await wrapped(URL_UNDER_TEST, { method: "POST" }); + + expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBeUndefined(); + }); + + it("reads nothing off an init that never went through the wrapper", () => { + expect(deadlineForRequestInit(undefined)).toBeUndefined(); + expect(deadlineForRequestInit({})).toBeUndefined(); + expect(deadlineForRequestInit({ method: "GET" })).toBeUndefined(); + }); + }); + describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => { const SERVER = "https://srv.example.com/mcp"; diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index 0eaa397b..37e71d9d 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -3,6 +3,7 @@ import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch. import { OAUTH_TIMEOUT_WIRE_CODE, OAuthRequestTimeoutError, + withOAuthRequestTimeout, } from "@inspector/core/auth/requestTimeout.js"; function ok(body: object): Response { @@ -334,4 +335,61 @@ describe("createRemoteFetch", () => { expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError); }); }); + + describe("carrying the caller's deadline to the route (#2319)", () => { + function envelopeOf(fetchFn: ReturnType>) { + const init = fetchFn.mock.calls[0][1] as RequestInit; + return JSON.parse(init.body as string) as Record; + } + + it("puts a bounded call's budget in the envelope, not in the headers", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(ok(standardRemoteBody)); + const remoteFetch = createRemoteFetch({ + baseUrl: "http://remote.example", + fetchFn: (input, init) => fetchFn(input, init), + }); + // Composed the way `InspectorClient` composes it: the wrapper outside, + // the remote fetch beneath. + const bounded = withOAuthRequestTimeout(remoteFetch, 7000); + + await bounded("http://upstream.example/token"); + + const envelope = envelopeOf(fetchFn); + expect(envelope.timeoutMs).toBe(7000); + // Not a header: `headers` is re-sent verbatim to the upstream server, so + // a marker there would leak the Inspector's internals to a third party. + expect(JSON.stringify(envelope.headers)).not.toContain("7000"); + }); + + it("omits the budget for an exempt call, so the route bounds nothing", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(ok(standardRemoteBody)); + const remoteFetch = createRemoteFetch({ + baseUrl: "http://remote.example", + fetchFn: (input, init) => fetchFn(input, init), + }); + const bounded = withOAuthRequestTimeout(remoteFetch, 7000, () => true); + + await bounded("http://upstream.example/mcp"); + + expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs"); + }); + + it("omits the budget for a direct call that no wrapper bounded", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(ok(standardRemoteBody)); + const remoteFetch = createRemoteFetch({ + baseUrl: "http://remote.example", + fetchFn: (input, init) => fetchFn(input, init), + }); + + await remoteFetch("http://upstream.example/mcp"); + + expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs"); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 9613d2af..09b756e8 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -51,7 +51,6 @@ interface StartOpts { secretStore?: SecretStore; logger?: pinoType.Logger; seedConfig?: string; - fetchTimeoutMs?: number; } async function start(opts: StartOpts = {}): Promise { @@ -66,9 +65,6 @@ async function start(opts: StartOpts = {}): Promise { initialConfig: { defaultEnvironment: {} }, secretStore: opts.secretStore ?? new InMemorySecretStore(), logger: opts.logger, - ...(opts.fetchTimeoutMs !== undefined && { - fetchTimeoutMs: opts.fetchTimeoutMs, - }), }); const { baseUrl, server } = await new Promise<{ baseUrl: string; @@ -475,19 +471,16 @@ describe("server.ts supplemental coverage", () => { await openStreamClosed; }); - it("answers 504 with a typed marker when its own deadline fires (#2319)", async () => { - // The backstop, on its own short budget so this costs milliseconds rather - // than the production thirty seconds. It matters because this is the only - // deadline on the transport-internal discovery path, which has no - // client-side wrapper — so its error has to survive the hop as something - // an `instanceof OAuthRequestTimeoutError` check can still recognize. - await stop(h); - h = await start({ fetchTimeoutMs: 120 }); - + it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => { + // The caller's budget travels in the envelope, so this costs milliseconds + // rather than the production thirty seconds. The marker matters because + // a backend-enforced deadline is the only one on the transport-internal + // discovery path, and its error has to survive the hop as something an + // `instanceof OAuthRequestTimeoutError` check can still recognize. const res = await fetch(`${h.baseUrl}/api/fetch`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ url: `${targetUrl}/stall` }), + body: JSON.stringify({ url: `${targetUrl}/stall`, timeoutMs: 120 }), }); expect(res.status).toBe(504); @@ -505,6 +498,65 @@ describe("server.ts supplemental coverage", () => { await stallClosed; }); + it("applies no deadline to a request that carries none (#2319)", async () => { + // ⚠️ The regression this guards: an unconditional timer here would abort + // a Streamable HTTP tool call that legitimately withholds its response + // headers — reintroducing on the backend exactly what `exemptMcpEndpoint` + // prevents on the client, and reporting it as an OAuth timeout besides. + const caller = new AbortController(); + const pending = fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stall` }), + signal: caller.signal, + }); + const settled = pending.then( + (r) => `responded ${r.status}`, + () => "aborted by us", + ); + + await vi.waitFor(() => { + expect(upstreamHits).toContain("/stall"); + }); + // Well past any budget the route might have applied on its own. Nothing + // should have answered: an unbounded request is still in flight. + await new Promise((r) => setTimeout(r, 400)); + expect( + await Promise.race([settled, Promise.resolve("still pending")]), + ).toBe("still pending"); + + caller.abort(); + expect(await settled).toBe("aborted by us"); + }); + + it("ignores a non-numeric deadline rather than trusting it (#2319)", async () => { + const caller = new AbortController(); + const pending = fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + url: `${targetUrl}/stall`, + timeoutMs: "soon", + }), + signal: caller.signal, + }); + const settled = pending.then( + (r) => `responded ${r.status}`, + () => "aborted by us", + ); + + await vi.waitFor(() => { + expect(upstreamHits).toContain("/stall"); + }); + await new Promise((r) => setTimeout(r, 300)); + expect( + await Promise.race([settled, Promise.resolve("still pending")]), + ).toBe("still pending"); + + caller.abort(); + expect(await settled).toBe("aborted by us"); + }); + it("routes the outbound request through HTTP_PROXY (#2067)", async () => { // /api/fetch is the browser's ONLY way out to the network — the web // client's `environment.fetch` is `createRemoteFetch()`, which forwards diff --git a/core/auth/index.ts b/core/auth/index.ts index e38812eb..918eaac3 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -56,6 +56,7 @@ export { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, OAUTH_TIMEOUT_WIRE_CODE, OAuthRequestTimeoutError, + deadlineForRequestInit, exemptMcpEndpoint, isOAuthRequestTimeoutWire, withOAuthRequestTimeout, diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 1f1df46c..d74bff90 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -148,6 +148,39 @@ export function exemptMcpEndpoint( }; } +/** + * The deadline a wrapped call is running under, keyed by the exact `RequestInit` + * the wrapper handed down (#2319). + * + * This is how the budget crosses the proxy hop without travelling *upstream*. + * `/api/fetch` must not time every request it serves — `createRemoteFetch` also + * carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold + * its response headers for minutes — so the route needs to know which requests + * are bounded and which are not (Copilot). A header would be the obvious + * carrier and is the wrong one: `serializeRequest` copies request headers + * verbatim into the payload and the route re-sends them to the authorization + * server, so a marker header would leak to a third party. + * + * A `WeakMap` keyed on the init object has neither problem. It is invisible to + * serialization, it needs no cast onto `RequestInit`, and it is collected with + * the object — the wrapper builds a fresh init per call, so there is one entry + * per in-flight request and nothing accumulates. + */ +const REQUEST_DEADLINES = new WeakMap(); + +/** + * The deadline `withOAuthRequestTimeout` stamped on this init, if any. + * + * `undefined` means the request is not bounded by this wrapper and must not be + * bounded by anything downstream either — an exempt MCP request reads as + * `undefined` because the exempt path never builds an init of its own. + */ +export function deadlineForRequestInit( + init: RequestInit | undefined, +): number | undefined { + return init ? REQUEST_DEADLINES.get(init) : undefined; +} + /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; @@ -342,9 +375,14 @@ export function withOAuthRequestTimeout( } }); + // Built once and stamped, so `createRemoteFetch` can read the budget off it + // and tell `/api/fetch` this particular request is bounded. + const nextInit: RequestInit = { ...init, signal }; + REQUEST_DEADLINES.set(nextInit, budget); + try { const response = await Promise.race([ - fetchFn(input, { ...init, signal }), + fetchFn(input, nextInit), abandoned, ]); // `fetch` resolves once the response *headers* arrive, so stopping here diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index bd54db8c..144f6681 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -22,6 +22,7 @@ */ import { + deadlineForRequestInit, isOAuthRequestTimeoutWire, OAuthRequestTimeoutError, } from "../../auth/requestTimeout.js"; @@ -154,10 +155,26 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { ? input.signal : undefined; + // #2319: tell the route whether *this* request is bounded, and by how much. + // The route serves MCP traffic as well as OAuth work, and a Streamable HTTP + // tool call can legitimately withhold its response headers for minutes — so + // a timer there must be per-request rather than unconditional (Copilot). An + // exempt request carries no deadline and the route applies none. + // + // In the JSON envelope rather than a header: `headers` is re-sent verbatim + // to the upstream server, so a marker header would leak to a third party. + const timeoutMs = deadlineForRequestInit(init); + const res = await fetchFn(`${baseUrl}/api/fetch`, { method: "POST", headers: reqHeaders, - body: JSON.stringify({ url, method, headers, body }), + body: JSON.stringify({ + url, + method, + headers, + body, + ...(timeoutMs !== undefined && { timeoutMs }), + }), signal, }); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 3e8a121b..b584bb1c 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -28,10 +28,7 @@ import { bodyLimit } from "hono/body-limit"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; import { createProxyFetch } from "../../node/proxyFetch.js"; -import { - DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, - OAUTH_TIMEOUT_WIRE_CODE, -} from "../../../auth/requestTimeout.js"; +import { OAUTH_TIMEOUT_WIRE_CODE } from "../../../auth/requestTimeout.js"; import type { RemoteConnectRequest, RemoteSendRequest, @@ -238,21 +235,6 @@ export interface RemoteServerOptions { /** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */ mcpConfigPath?: string; - /** - * Deadline for the outbound request `/api/fetch` makes, in milliseconds - * (#2319). Defaults to {@link DEFAULT_OAUTH_REQUEST_TIMEOUT_MS}, the same - * budget the browser-side `withOAuthRequestTimeout` uses, so the two agree on - * when an attempt is hopeless. - * - * This is the backstop for a client that vanishes without aborting; the - * primary mechanism is the caller's own signal, which `createRemoteFetch` - * forwards onto this hop. Exposed rather than hardcoded so a test can prove - * the backstop actually fires without spending thirty seconds of wall clock - * on it — and because a very slow authorization server is a legitimate reason - * to want a longer one. - */ - fetchTimeoutMs?: number; - /** * When false, the server list is read-only for this session: all * `/api/servers` mutations (POST/PUT/DELETE/order) are rejected with 403, @@ -588,10 +570,6 @@ export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth; - const fetchTimeoutMs = Math.max( - 0, - Math.round(options.fetchTimeoutMs ?? DEFAULT_OAUTH_REQUEST_TIMEOUT_MS), - ); // Determine auth token when auth is enabled: options > env var > generate const authToken = dangerouslyOmitAuth @@ -1227,6 +1205,8 @@ export function createRemoteApp( method?: string; headers?: Record; body?: string; + /** Per-request deadline, set only by a bounded OAuth call (#2319). */ + timeoutMs?: number; }; try { body = (await c.req.json()) as typeof body; @@ -1234,7 +1214,13 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { url, method = "GET", headers = {}, body: reqBody } = body; + const { + url, + method = "GET", + headers = {}, + body: reqBody, + timeoutMs: requestedTimeoutMs, + } = body; if (!url) { return c.json({ error: "Missing url" }, 400); } @@ -1262,14 +1248,30 @@ export function createRemoteApp( // the body has been either read or explicitly cancelled — this route never // hands a live stream to its caller, so there is nothing left for the timer // to protect and nothing it could sever. - const timer = setTimeout(() => { - deadlineFired = true; - controller.abort( - new Error( - `proxied request to ${url} timed out after ${fetchTimeoutMs}ms`, - ), - ); - }, fetchTimeoutMs); + // ⚠️ Only the caller decides whether this request is bounded, and by how + // much. This route is the browser's way out to the network for MCP traffic + // as well as OAuth work, so an unconditional timer here would abort a + // Streamable HTTP tool call that legitimately withholds its response + // headers for longer than the budget — reintroducing on the backend exactly + // the regression `exemptMcpEndpoint` exists to prevent on the client, and + // reporting it as an OAuth timeout besides (Copilot). No deadline in the + // envelope means no timer at all. + const deadlineMs = + typeof requestedTimeoutMs === "number" && + Number.isFinite(requestedTimeoutMs) + ? Math.max(0, Math.round(requestedTimeoutMs)) + : undefined; + const timer = + deadlineMs === undefined + ? undefined + : setTimeout(() => { + deadlineFired = true; + controller.abort( + new Error( + `proxied request to ${url} timed out after ${deadlineMs}ms`, + ), + ); + }, deadlineMs); try { // Proxy-aware, not the bare global. This route is the browser's ONLY way @@ -1332,14 +1334,14 @@ export function createRemoteApp( error: msg, code: OAUTH_TIMEOUT_WIRE_CODE, url, - timeoutMs: fetchTimeoutMs, + timeoutMs: deadlineMs, }, 504, ); } return c.json({ error: msg }, 500); } finally { - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); } }); From 93965936ba220dd88ea52925f96b8c2e2e915b03 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 05:42:00 -0400 Subject: [PATCH 021/120] docs: describe the shipped timeout architecture (#2319 round 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments still said transport-internal discovery has no client-side wrapper, or that the wrapper must never be applied to the transport fetch. Round 11 made both false, and the second actively tells a caller to do the opposite of what InspectorClient does — while hiding that isExempt is the safety-critical part. - createRemoteFetch's marker comment now gives the real reason the reconstruction matters: both ends run the same budget, so this path is the backend winning the race, most plausibly a backgrounded tab throttling setTimeout while the server's fires on schedule. Which deadline fired must not decide whether the error names its endpoint. - The body-buffering comment now says buffering is the second reason isExempt is not optional on a mixed chain, since an exempt request skips it and an SSE body stays open rather than being drained. - The matching test comment is reframed as testing the proxy boundary independently of which deadline wins. The PR description is rewritten around what is actually shipped: both chains bounded and differing only in the exemption, the per-request envelope that replaced the fetchTimeoutMs option, and the three mutation-verified tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../test/core/mcp/remote/createRemoteFetch.test.ts | 10 ++++++---- core/auth/requestTimeout.ts | 7 ++++--- core/mcp/remote/createRemoteFetch.ts | 11 ++++++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index 37e71d9d..27a42d61 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -272,10 +272,12 @@ describe("createRemoteFetch", () => { } it("rebuilds an OAuthRequestTimeoutError from the route's marker", async () => { - // The one path where a timeout reaches the browser without a client-side - // wrapper having fired first: the discovery the SDK runs from inside the - // transport has no wrapper at all, so the backend's deadline is the only - // one, and it arrives as an ordinary error response. + // The proxy boundary in isolation: whichever end's deadline fires, a + // timeout that comes back as an ordinary error response must still + // reconstruct as a typed one. Both ends run the same budget in + // production, so this path is the backend winning the race — a + // backgrounded tab throttling `setTimeout` is the plausible case — and + // which one fired must not decide whether the error names its endpoint. const remoteFetch = remoteReturning( 504, JSON.stringify({ diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index d74bff90..28efdbc4 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -391,9 +391,10 @@ export function withOAuthRequestTimeout( // `response.json()` would hang with nothing watching it (Copilot). Every // response on this path is a small, finite document — metadata, // a registration, a token — so buffering it under the same deadline - // bounds the whole exchange. This is the other reason the wrapper must - // never be applied to the transport fetch, whose bodies are streams that - // are supposed to stay open. + // bounds the whole exchange. It is also the second reason `isExempt` is + // not optional on a mixed chain: an exempt request skips this buffering + // entirely, which is what lets an SSE body stay open rather than being + // drained into memory here. const body = await Promise.race([response.arrayBuffer(), abandoned]); return rebuildResponse(response, body); } catch (err) { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 144f6681..d0757956 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -184,9 +184,14 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { // rebuild the typed error from its marker so the `instanceof` checks // downstream — most importantly the one that lets a stalled probe escape // `withRfc8414OidcCompat` — still see a timeout for what it is (#2319, - // Copilot). This is the only path on which such a timeout can reach the - // browser without a client-side wrapper having fired first: the discovery - // the SDK runs from inside the transport has no wrapper at all. + // Copilot). + // + // Both ends run the same budget, so the client's race usually settles + // first and this path is not taken. It matters when the backend's timer + // wins anyway — most plausibly a backgrounded tab, where the browser + // throttles `setTimeout` while the server's fires on schedule. Without + // this, which of two equal deadlines happened to fire would decide + // whether the error carried its endpoint. let parsed: unknown; try { parsed = JSON.parse(text); From e234343702386248c8b9d20f3d19214326f76a8c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 06:06:36 -0400 Subject: [PATCH 022/120] fix: cancellable compat probes and a validated budget (#2319 round 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs and two stale rationales. The OIDC fallback probes were issued with `{ headers }` and no signal, so a caller that aborted after the initial 404 got a timeout reported in place of its own cancellation, while the probe ran on to its own deadline. The probes now carry the caller's signal — via the shared `callerSignalOf`, so the precedence rule has one definition — a pre-aborted caller is not probed at all, and an abort is rethrown rather than folded back into the preceding response. `Math.max(0, Math.round(timeoutMs))` let NaN and Infinity through. `setTimeout(fn, NaN)` fires immediately, and a delay above 2**31-1 overflows a 32-bit signed int and falls back to 1ms — so a caller asking for a very large budget, or one whose arithmetic produced NaN, got an immediate OAuth timeout. `normalizeBudget` falls back to the default on non-finite and clamps to the timer's range; the route's envelope-supplied deadline gets the same clamp, but ignores a non-finite value outright since a malformed deadline reads safest as no deadline. The wire-marker docblock and the matching route test still said a backend deadline is the only one on the transport-internal discovery path, which round 11 changed. Both now give the real reason the marker matters: both ends run the same budget, and it is the backend winning the race — a backgrounded tab throttling setTimeout — that must not decide whether the error names its endpoint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../core/auth/oidcDiscoveryCompat.test.ts | 79 +++++++++++++++++++ .../src/test/core/auth/requestTimeout.test.ts | 40 ++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 10 ++- core/auth/oidcDiscoveryCompat.ts | 21 ++++- core/auth/requestTimeout.ts | 41 ++++++++-- core/mcp/remote/node/server.ts | 7 +- 6 files changed, 185 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts index e3bf9d4d..acb354aa 100644 --- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts +++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts @@ -439,3 +439,82 @@ describe("withRfc8414OidcCompat", () => { expect(base).toHaveBeenCalledTimes(2); }); }); + +describe("probe cancellation (#2319)", () => { + const RFC8414 = + "https://as.example.com/.well-known/oauth-authorization-server/tenant"; + + /** 404 on the RFC 8414 leg, then never answers the OIDC probe. */ + function stallingProbeFetch() { + return vi.fn((input, init) => { + if (String(input) === RFC8414) { + return Promise.resolve(new Response(null, { status: 404 })); + } + return new Promise((_resolve, reject) => { + // A real `fetch` rejects immediately on a signal that is already + // aborted, so the double is only useful if it does the same. + if (init?.signal?.aborted) { + reject(init.signal.reason as Error); + return; + } + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason as Error), + ); + }); + }); + } + + it("carries the caller's signal into the probe", async () => { + const inner = stallingProbeFetch(); + const wrapped = withRfc8414OidcCompat(inner); + const caller = new AbortController(); + + const settled = wrapped(RFC8414, { signal: caller.signal }).then( + (r) => r, + (e: unknown) => e, + ); + // Wait until the probe is actually in flight, so the abort exercises + // cancellation rather than the pre-aborted short-circuit. + await vi.waitFor(() => { + expect(inner).toHaveBeenCalledTimes(2); + }); + const reason = new Error("caller gave up"); + caller.abort(reason); + + // Not the preceding 404: substituting it would report a discovery failure + // for a request the caller deliberately cancelled. + expect(await settled).toBe(reason); + expect((inner.mock.calls[1][1] as RequestInit).signal).toBe(caller.signal); + }); + + it("does not issue a probe the caller already cancelled", async () => { + const inner = stallingProbeFetch(); + const wrapped = withRfc8414OidcCompat(inner); + const reason = new Error("already gone"); + + const settled = wrapped(RFC8414, { + signal: AbortSignal.abort(reason), + }).then( + (r) => r, + (e: unknown) => e, + ); + + expect(await settled).toBe(reason); + // Only the RFC 8414 leg, which the caller did make. + expect(inner).toHaveBeenCalledTimes(1); + }); + + it("still falls back to the original response on an ordinary probe failure", async () => { + const inner = vi.fn((input) => { + if (String(input) === RFC8414) { + return Promise.resolve(new Response(null, { status: 404 })); + } + return Promise.reject(new TypeError("network error")); + }); + const wrapped = withRfc8414OidcCompat(inner); + + const response = await wrapped(RFC8414); + + expect(response.status).toBe(404); + }); +}); diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index dbd89bcc..95f23034 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -387,6 +387,46 @@ describe("withOAuthRequestTimeout", () => { }); }); + it("falls back to the default on a non-finite budget", async () => { + vi.useFakeTimers(); + // `NaN` survives `Math.max(0, Math.round(NaN))` and `setTimeout(fn, NaN)` + // fires immediately, so without this every OAuth request under a budget + // that came out of bad arithmetic would fail at once with "timed out after + // NaNms". + for (const bad of [NaN, Infinity, -Infinity]) { + const wrapped = withOAuthRequestTimeout(neverSettles, bad); + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + + await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1); + expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe( + "pending", + ); + + await vi.advanceTimersByTimeAsync(1); + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, + }); + } + }); + + it("clamps an over-large budget to what setTimeout can schedule", async () => { + vi.useFakeTimers(); + // Past 2**31-1 the delay overflows a 32-bit signed int and Node falls back + // to 1ms — an immediate timeout, the opposite of what was asked for. + const wrapped = withOAuthRequestTimeout(neverSettles, 2 ** 40); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe( + "pending", + ); + + await vi.advanceTimersByTimeAsync(2_147_483_647); + expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({ + timeoutMs: 2_147_483_647, + }); + }); + it("clamps a negative budget to zero rather than throwing", async () => { vi.useFakeTimers(); const wrapped = withOAuthRequestTimeout(neverSettles, -1); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 09b756e8..96e3bd15 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -473,10 +473,12 @@ describe("server.ts supplemental coverage", () => { it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => { // The caller's budget travels in the envelope, so this costs milliseconds - // rather than the production thirty seconds. The marker matters because - // a backend-enforced deadline is the only one on the transport-internal - // discovery path, and its error has to survive the hop as something an - // `instanceof OAuthRequestTimeoutError` check can still recognize. + // rather than the production thirty seconds. What it pins is typed-marker + // preservation when the *backend* wins the client-side race — both ends + // run the same budget, and a backgrounded tab throttling `setTimeout` is + // the plausible way the server's fires first. The error has to survive + // the hop as something an `instanceof OAuthRequestTimeoutError` check can + // still recognize, whichever end produced it. const res = await fetch(`${h.baseUrl}/api/fetch`, { method: "POST", headers: { "content-type": "application/json" }, diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index 4404adc0..06ba7f43 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -90,7 +90,7 @@ * wiring in `core/mcp/inspectorClient.ts`. */ -import { OAuthRequestTimeoutError } from "./requestTimeout.js"; +import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js"; import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, @@ -249,6 +249,13 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { if (candidates.length === 0) return response; const headers = discoveryHeaders(input, init); + // The probe is a request the caller never made, issued on its behalf — so + // it has to be cancellable by the caller too. Without the signal, aborting + // after the initial 404 left the probe running to its own deadline and the + // caller was handed a timeout in place of its own abort (Copilot). Same + // precedence rule as everywhere else, hence the shared helper rather than a + // second copy of it. + const callerSignal = callerSignalOf(input, init); // The loop advances to the next candidate only where the SDK's own loop // would. Anywhere else it hands the original response back and lets // discovery run its normal course, because *promoting a later candidate @@ -260,9 +267,15 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // those, returning `response` leaves the SDK to make the same request and // reach the same verdict it always would. for (const candidate of candidates) { + // Don't issue a probe the caller has already given up on. A real `fetch` + // rejects immediately on a pre-aborted signal, so this is mostly about + // not sending the request at all — the same reasoning as the + // short-circuit in `withOAuthRequestTimeout`. + if (callerSignal?.aborted) throw callerSignal.reason; + let probe: Response; try { - probe = await fetchFn(candidate, { headers }); + probe = await fetchFn(candidate, { headers, signal: callerSignal }); } catch (err) { // A deadline the Inspector imposed is *our* error, not the server's // answer, so it escapes rather than being folded back into the original @@ -275,6 +288,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // stall the SDK would have hit itself, unbounded. Everything else — a // CORS rejection, DNS, a reset — still falls back as documented above. if (err instanceof OAuthRequestTimeoutError) throw err; + // Likewise a caller that gave up: substituting the preceding 404 for + // its own abort would report a discovery failure for a request it + // deliberately cancelled. + if (callerSignal?.aborted) throw err; return response; } if (!probe.ok) { diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 28efdbc4..511e566a 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -83,6 +83,33 @@ export class OAuthRequestTimeoutError extends Error { } } +/** + * The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the + * delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an + * `Infinity` or an over-large budget produces an *immediate* timeout — the exact + * opposite of what the caller asked for (Copilot). + */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** + * Coerce a caller's budget into something `setTimeout` can honour and the error + * can honestly report. + * + * - Non-finite (`NaN`, `±Infinity`) falls back to the default. `NaN` survives + * `Math.max(0, Math.round(...))` and `setTimeout(fn, NaN)` fires immediately, + * so a caller whose arithmetic produced a `NaN` would otherwise see every + * OAuth request fail at once with "timed out after NaNms". + * - Above `MAX_TIMER_DELAY_MS` it clamps rather than overflowing to 1ms. + * - Negative clamps to 0, which is a real budget (fire on the next tick), not + * an error. + * + * Rounded because the budget is *reported* — see the note at the call site. + */ +function normalizeBudget(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs)) return DEFAULT_OAUTH_REQUEST_TIMEOUT_MS; + return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, Math.round(timeoutMs))); +} + /** * How a proxy-side deadline is carried back to the browser (#2319). * @@ -90,10 +117,12 @@ export class OAuthRequestTimeoutError extends Error { * `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a * timeout enforced by the *backend* would arrive as an untyped error and the * `instanceof OAuthRequestTimeoutError` checks downstream would all be false - * (Copilot). That matters most exactly where there is no client-side wrapper to - * have fired first: the discovery the SDK runs from inside the transport. So - * the route stamps this marker and `createRemoteFetch` reconstructs the typed - * error from it. + * (Copilot). Both ends run the same budget, so the client's race usually + * settles first; this matters when the backend's timer wins anyway — most + * plausibly a backgrounded tab, where the browser throttles `setTimeout` while + * the server's fires on schedule. Which of two equal deadlines happened to fire + * must not decide whether the error names its endpoint. So the route stamps + * this marker and `createRemoteFetch` reconstructs the typed error from it. */ export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout"; @@ -204,7 +233,7 @@ function requestUrlOf(input: RequestInfo | URL): string { * `Request`'s signal rather than clear it (Copilot). An explicit `null` is the * genuine "no signal" override and is honoured as one. */ -function callerSignalOf( +export function callerSignalOf( input: RequestInfo | URL, init: RequestInit | undefined, ): AbortSignal | undefined { @@ -324,7 +353,7 @@ export function withOAuthRequestTimeout( // public field. Rounding rather than flooring so a caller's own // whole-millisecond timeout survives the trip through that clock and is still // the number the message names. - const budget = Math.max(0, Math.round(timeoutMs)); + const budget = normalizeBudget(timeoutMs); return async (input, init) => { const url = requestUrlOf(input); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index b584bb1c..62819249 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1256,10 +1256,15 @@ export function createRemoteApp( // the regression `exemptMcpEndpoint` exists to prevent on the client, and // reporting it as an OAuth timeout besides (Copilot). No deadline in the // envelope means no timer at all. + // Clamped to what `setTimeout` can schedule: past 2**31-1 the delay + // overflows and Node falls back to 1ms, turning an over-large budget into + // an immediate timeout. Non-finite is ignored outright rather than + // defaulted — an envelope carrying `NaN` is a malformed request, and the + // safe reading of a malformed deadline is "no deadline". const deadlineMs = typeof requestedTimeoutMs === "number" && Number.isFinite(requestedTimeoutMs) - ? Math.max(0, Math.round(requestedTimeoutMs)) + ? Math.min(2_147_483_647, Math.max(0, Math.round(requestedTimeoutMs))) : undefined; const timer = deadlineMs === undefined From 5a1c157a7965c519b615173fa9894a2225f6fe43 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 06:24:24 -0400 Subject: [PATCH 023/120] fix: exempt MCP traffic by origin, not path (#2319 round 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exemptMcpEndpoint compared origin + pathname, which misses legacy SSE entirely: SSEClientTransport opens the configured URL and is handed a SEPARATE message endpoint by the server, then POSTs every JSON-RPC message to that second pathname. Those were bounded and body-buffered as OAuth traffic, so a slow tool call over SSE would be severed at 30s and reported as an OAuthRequestTimeoutError. It compares origin alone now. That is exact rather than approximate: the SDK enforces that the SSE message endpoint shares the stream URL's origin and rejects one that does not, so every MCP request this chain carries is same-origin with the configured URL by construction. The cost, stated in the docblock and pinned by a test: an OAuth endpoint on the MCP server's own origin is exempt here and so unbounded on this chain. The OAuth chain still bounds the Inspector's own requests to it, so only the SDK's transport-internal OAuth against a same-origin server falls back to where it was before #2319. Caller cancellation was also still swallowed in two later places in the compat probe loop — `probe.text()` has its own catch, and `fetch` resolves on headers, so an abort mid-body landed there and was answered with the preceding 404; and an abort during releaseBody's await would have carried on probing. Both recheck the signal now, and all three sites throw the caller's own `reason` rather than whatever shape the layer beneath rejected with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../core/auth/oidcDiscoveryCompat.test.ts | 39 ++++++++++++++++++ .../src/test/core/auth/requestTimeout.test.ts | 40 ++++++++++++++++--- core/auth/oidcDiscoveryCompat.ts | 19 ++++++++- core/auth/requestTimeout.ts | 26 +++++++++--- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts index acb354aa..511f307b 100644 --- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts +++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts @@ -504,6 +504,45 @@ describe("probe cancellation (#2319)", () => { expect(inner).toHaveBeenCalledTimes(1); }); + it("rethrows an abort that lands while the probe body is pending", async () => { + // `fetch` resolves on headers, so a late abort rejects `probe.text()` + // rather than the fetch — a separate catch, which used to answer with the + // preceding 404. + const caller = new AbortController(); + const inner = vi.fn((input, init) => { + if (String(input) === RFC8414) { + return Promise.resolve(new Response(null, { status: 404 })); + } + // Headers now, body never. + return Promise.resolve( + new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode('{"issuer":')); + init?.signal?.addEventListener("abort", () => + ctrl.error(init.signal?.reason as Error), + ); + }, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + }); + const wrapped = withRfc8414OidcCompat(inner); + + const settled = wrapped(RFC8414, { signal: caller.signal }).then( + (r) => r, + (e: unknown) => e, + ); + await vi.waitFor(() => { + expect(inner).toHaveBeenCalledTimes(2); + }); + const reason = new Error("caller gave up mid-body"); + caller.abort(reason); + + expect(await settled).toBe(reason); + }); + it("still falls back to the original response on an ordinary probe failure", async () => { const inner = vi.fn((input) => { if (String(input) === RFC8414) { diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 95f23034..8ee0559f 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -506,17 +506,46 @@ describe("withOAuthRequestTimeout", () => { expect(isExempt(`${SERVER}#frag`)).toBe(true); }); - it("bounds OAuth work on the same chain", () => { + it("exempts legacy SSE's separate message endpoint", () => { + // The case a path rule gets wrong: `SSEClientTransport` opens the + // configured URL and is handed a *different* pathname to POST every + // JSON-RPC message to. Bounding those would sever a slow tool call over + // SSE and report it as an OAuth timeout. The SDK enforces that the + // message endpoint shares the stream URL's origin, which is what makes + // an origin rule exact rather than approximate. + const isExempt = exemptMcpEndpoint(() => "https://srv.example.com/sse"); + + expect(isExempt("https://srv.example.com/messages?sessionId=abc")).toBe( + true, + ); + expect(isExempt("https://srv.example.com/")).toBe(true); + }); + + it("bounds OAuth work on a different origin", () => { + const isExempt = exemptMcpEndpoint(() => SERVER); + + expect(isExempt("https://as.example.com/token")).toBe(false); + expect( + isExempt("https://as.example.com/.well-known/openid-configuration"), + ).toBe(false); + // Origin is scheme + host + port, so any of the three differing bounds it. + expect(isExempt("http://srv.example.com/token")).toBe(false); + expect(isExempt("https://srv.example.com:8443/token")).toBe(false); + }); + + it("exempts a same-origin OAuth endpoint, which is the cost of the rule", () => { + // Stated as a test rather than left implicit: protected-resource metadata + // lives on the resource's own origin, so it is exempt here. The OAuth + // chain still bounds the Inspector's own requests to it; only the SDK's + // transport-internal OAuth against a same-origin server falls back to the + // SDK's per-request timeout. const isExempt = exemptMcpEndpoint(() => SERVER); expect( isExempt( "https://srv.example.com/.well-known/oauth-protected-resource/mcp", ), - ).toBe(false); - expect(isExempt("https://as.example.com/token")).toBe(false); - // Same origin, different path — still not the endpoint. - expect(isExempt("https://srv.example.com/mcp/register")).toBe(false); + ).toBe(true); }); it("fails open when the server URL is unknown or unparseable", () => { @@ -540,6 +569,7 @@ describe("withOAuthRequestTimeout", () => { expect(isExempt("https://other.example.com/mcp")).toBe(false); current = "https://other.example.com/mcp"; expect(isExempt("https://other.example.com/mcp")).toBe(true); + expect(isExempt(SERVER)).toBe(false); }); }); diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index 06ba7f43..760b3540 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -290,8 +290,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { if (err instanceof OAuthRequestTimeoutError) throw err; // Likewise a caller that gave up: substituting the preceding 404 for // its own abort would report a discovery failure for a request it - // deliberately cancelled. - if (callerSignal?.aborted) throw err; + // deliberately cancelled. The caller's own `reason` is what surfaces, + // here and at the two sites below, rather than whatever shape the + // underlying layer happened to reject with. + if (callerSignal?.aborted) throw callerSignal.reason; return response; } if (!probe.ok) { @@ -301,6 +303,11 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // candidate exhaust the origin's pool (Copilot). Same discipline as // `core/mcp/node/authChallengeFetch.ts`. await releaseBody(probe); + // Rechecked after the release, which awaits: an abort that lands while + // the body is being discarded must not be answered with the preceding + // response either, and `continue` would otherwise carry on probing for + // a caller that has stopped waiting. + if (callerSignal?.aborted) throw callerSignal.reason; if (continuesDiscovery(probe.status)) continue; return response; } @@ -317,6 +324,14 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { body = await probe.text(); parsed = JSON.parse(body); } catch { + // `fetch` resolves on headers, so the abort can land here rather than + // in the catch above: the probe's headers arrived, the caller gave up, + // and `probe.text()` rejects on the partial body. Substituting the + // preceding 404 would hand a standalone user of this wrapper a + // discovery failure in place of its own abort reason (Copilot). A body + // that merely will not parse still falls through, which is the case + // this catch exists for. + if (callerSignal?.aborted) throw callerSignal.reason; return response; } if (!isRfc8414OnlyMetadata(parsed)) { diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 511e566a..3e6c478e 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -157,9 +157,25 @@ export function isOAuthRequestTimeoutWire( * uncertain — no server URL yet, a URL that will not parse, either side — is * treated as the MCP endpoint and exempted. * - * Compared on origin + pathname: the SDK varies method and query across its MCP - * requests but not the endpoint, while OAuth discovery goes to a different path - * (`/.well-known/…`) and usually a different origin entirely. + * Compared on **origin alone**, not origin + pathname. Path matching looks more + * precise and is wrong: legacy SSE opens the configured URL (`/sse`), is handed + * a *separate* message endpoint by the server, and POSTs every JSON-RPC message + * to that second pathname — so a path rule bounds and body-buffers real MCP + * traffic, and a slow tool call over SSE is severed at 30s and reported as an + * `OAuthRequestTimeoutError` (Copilot). + * + * Origin is not a loose approximation of the right rule; it is exact, because + * the SDK *enforces* that the SSE message endpoint shares the stream URL's + * origin and rejects one that does not. Every MCP request this chain carries is + * therefore same-origin with the configured URL, by construction. + * + * What it costs: an OAuth endpoint hosted on the MCP server's own origin — the + * protected-resource metadata document, or an authorization server deployed + * beside the resource — is exempt here and so unbounded on this chain. That is + * the fail-open direction again, and the loss is narrow: the OAuth *chain* + * bounds the Inspector's own discovery and token requests to those same URLs, + * so only the SDK's transport-internal OAuth against a same-origin server falls + * back to the SDK's per-request timeout, which is where it was before #2319. */ export function exemptMcpEndpoint( getServerUrl: () => string | undefined, @@ -168,9 +184,7 @@ export function exemptMcpEndpoint( const serverUrl = getServerUrl(); if (!serverUrl) return true; try { - const a = new URL(url); - const b = new URL(serverUrl); - return a.origin === b.origin && a.pathname === b.pathname; + return new URL(url).origin === new URL(serverUrl).origin; } catch { return true; } From 42339ff4a45e1f983d343655e35c1971d8259194 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 06:40:07 -0400 Subject: [PATCH 024/120] fix: narrow the exemption per transport (#2319 round 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 17 widened exemptMcpEndpoint to the origin for everything, which exempted same-origin OAuth work as well as MCP traffic — on the very 401/discovery path #2319 exists to bound. The rule is now as narrow as each transport allows. Streamable HTTP sends every MCP request to the configured URL, so origin + pathname is exact there and same-origin OAuth stays bounded. Legacy SSE negotiates its message endpoint inside the stream, where we never see it, so the origin rule is the narrowest available — and it is exact rather than approximate, since the SDK enforces that the message endpoint shares the stream URL's origin. The residual gap is therefore confined to legacy SSE against a server hosting its own OAuth endpoints, and it is stated in the docblock and pinned by a test rather than left implicit. Neither alternative was reachable: the SDK takes a single fetch for all network requests, so there is no separate auth seam to route through, and the negotiated SSE endpoint is parsed inside the SDK's own stream handler. Fails open on every uncertainty, including not knowing the transport — a path rule applied to what turns out to be SSE severs real MCP traffic, so an absent or throwing hint falls back to the origin rule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 67 ++++++++++++++----- core/auth/requestTimeout.ts | 67 ++++++++++++++----- core/mcp/inspectorClient.ts | 9 ++- 3 files changed, 109 insertions(+), 34 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 8ee0559f..6cd232e7 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -506,6 +506,58 @@ describe("withOAuthRequestTimeout", () => { expect(isExempt(`${SERVER}#frag`)).toBe(true); }); + /** Streamable HTTP: every MCP request goes to the configured URL. */ + const streamable = (serverUrl: string) => + exemptMcpEndpoint( + () => serverUrl, + () => false, + ); + /** Legacy SSE: the message endpoint arrives inside the stream. */ + const sse = (serverUrl: string) => + exemptMcpEndpoint( + () => serverUrl, + () => true, + ); + + it("bounds same-origin OAuth work on a Streamable HTTP connection", () => { + // The narrow rule, available because the endpoint is the configured URL. + const isExempt = streamable(SERVER); + + expect(isExempt(SERVER)).toBe(true); + expect( + isExempt( + "https://srv.example.com/.well-known/oauth-protected-resource/mcp", + ), + ).toBe(false); + expect(isExempt("https://srv.example.com/token")).toBe(false); + }); + + it("widens to the origin on legacy SSE, whose message path is unknowable", () => { + // The cost of the wider rule, asserted rather than implied: same-origin + // OAuth is exempt here, and falls back to the SDK's per-request timeout. + const isExempt = sse("https://srv.example.com/sse"); + + expect( + isExempt("https://srv.example.com/.well-known/openid-configuration"), + ).toBe(true); + expect(isExempt("https://as.example.com/token")).toBe(false); + }); + + it("falls back to the origin rule when the transport is unknown", () => { + // Fails open on this uncertainty like every other: a path rule applied to + // a connection that turns out to be SSE severs real MCP traffic. + const noHint = exemptMcpEndpoint(() => SERVER); + const throws = exemptMcpEndpoint( + () => SERVER, + () => { + throw new Error("not yet connected"); + }, + ); + + expect(noHint("https://srv.example.com/token")).toBe(true); + expect(throws("https://srv.example.com/token")).toBe(true); + }); + it("exempts legacy SSE's separate message endpoint", () => { // The case a path rule gets wrong: `SSEClientTransport` opens the // configured URL and is handed a *different* pathname to POST every @@ -533,21 +585,6 @@ describe("withOAuthRequestTimeout", () => { expect(isExempt("https://srv.example.com:8443/token")).toBe(false); }); - it("exempts a same-origin OAuth endpoint, which is the cost of the rule", () => { - // Stated as a test rather than left implicit: protected-resource metadata - // lives on the resource's own origin, so it is exempt here. The OAuth - // chain still bounds the Inspector's own requests to it; only the SDK's - // transport-internal OAuth against a same-origin server falls back to the - // SDK's per-request timeout. - const isExempt = exemptMcpEndpoint(() => SERVER); - - expect( - isExempt( - "https://srv.example.com/.well-known/oauth-protected-resource/mcp", - ), - ).toBe(true); - }); - it("fails open when the server URL is unknown or unparseable", () => { // The two errors are not symmetric: bounding what should not be bounded // severs a long-running tool call, while failing to bound leaves the diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 3e6c478e..46943308 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -157,34 +157,65 @@ export function isOAuthRequestTimeoutWire( * uncertain — no server URL yet, a URL that will not parse, either side — is * treated as the MCP endpoint and exempted. * - * Compared on **origin alone**, not origin + pathname. Path matching looks more - * precise and is wrong: legacy SSE opens the configured URL (`/sse`), is handed - * a *separate* message endpoint by the server, and POSTs every JSON-RPC message - * to that second pathname — so a path rule bounds and body-buffers real MCP - * traffic, and a slow tool call over SSE is severed at 30s and reported as an - * `OAuthRequestTimeoutError` (Copilot). + * The comparison is **as narrow as the transport allows**, which is not the same + * rule for both of them: * - * Origin is not a loose approximation of the right rule; it is exact, because - * the SDK *enforces* that the SSE message endpoint shares the stream URL's - * origin and rejects one that does not. Every MCP request this chain carries is - * therefore same-origin with the configured URL, by construction. + * - **Streamable HTTP** sends every MCP request to the configured URL, so + * `origin + pathname` is exact. Same-origin OAuth work — a protected-resource + * document, or an authorization server deployed beside the resource — is a + * different path and stays bounded, which is what #2319 asks for (Copilot). + * - **Legacy SSE** opens the configured URL (`/sse`), is handed a *separate* + * message endpoint by the server, and POSTs every JSON-RPC message to that + * second pathname. We never see that URL — it arrives inside the stream, in + * the SDK's own parser — so a path rule there bounds and body-buffers real + * MCP traffic, severing a slow tool call at 30s and reporting it as an + * `OAuthRequestTimeoutError`. Origin is the narrowest rule available, and it + * is exact rather than approximate: the SDK *enforces* that the message + * endpoint shares the stream URL's origin and rejects one that does not. * - * What it costs: an OAuth endpoint hosted on the MCP server's own origin — the - * protected-resource metadata document, or an authorization server deployed - * beside the resource — is exempt here and so unbounded on this chain. That is - * the fail-open direction again, and the loss is narrow: the OAuth *chain* - * bounds the Inspector's own discovery and token requests to those same URLs, - * so only the SDK's transport-internal OAuth against a same-origin server falls - * back to the SDK's per-request timeout, which is where it was before #2319. + * So the residual gap is confined to legacy SSE against a server that also + * hosts its own OAuth endpoints, where transport-internal OAuth falls back to + * the SDK's per-request timeout — where it was before #2319. The Inspector's + * own discovery and token requests to those URLs are bounded regardless, on the + * OAuth chain. + * + * ⚠️ **Fails open** on every uncertainty, and the uncertainties differ by rule: + * no server URL, an unparseable URL on either side, or *not knowing which + * transport this is* all resolve to exempt — the last one by falling back to + * the wider origin rule. */ +/** + * Read the transport hint, treating "absent" and "threw" alike as the wide + * case. A path rule applied to a connection that turns out to be legacy SSE + * severs real MCP traffic, so every uncertainty resolves toward the origin rule. + */ +function readNegotiatesEndpoint(hint: (() => boolean) | undefined): boolean { + if (!hint) return true; + try { + return hint(); + } catch { + return true; + } +} + export function exemptMcpEndpoint( getServerUrl: () => string | undefined, + /** + * Whether the message endpoint is negotiated rather than configured — true + * for legacy SSE. Read per call, since the transport can change between + * connects. Absent, or throwing, is treated as `true`. + */ + negotiatesEndpoint?: () => boolean, ): (url: string) => boolean { return (url) => { const serverUrl = getServerUrl(); if (!serverUrl) return true; + const negotiated = readNegotiatesEndpoint(negotiatesEndpoint); try { - return new URL(url).origin === new URL(serverUrl).origin; + const a = new URL(url); + const b = new URL(serverUrl); + if (a.origin !== b.origin) return false; + return negotiated || a.pathname === b.pathname; } catch { return true; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 94abbc87..29456ff4 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -840,7 +840,14 @@ export class InspectorClient extends InspectorClientEventTarget { withOAuthRequestTimeout( withOverrides, DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, - exemptMcpEndpoint(() => this.getServerUrl()), + exemptMcpEndpoint( + () => this.getServerUrl(), + // Legacy SSE negotiates its message endpoint inside the stream, so + // its path is unknowable here and the exemption has to widen to the + // origin. Streamable HTTP sends everything to the configured URL, so + // the path rule is exact there and same-origin OAuth stays bounded. + () => this.transportConfig?.type === "sse", + ), ), ); // #2319: the auth chain is composed separately so the deadline sits From e71b8b9dfb57ec57c4d363e3addc7f9e1c0c36b2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 07:10:00 -0400 Subject: [PATCH 025/120] fix: never exempt a form-encoded request (#2319 round 20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exemption was URL-only, so an authorization server configured at the MCP endpoint's own URL — `oauthTokenUrl` takes any absolute URL — made the transport-internal refresh take the exempt branch and run with no signal, no body buffering and no deadline. That is the one request this change exists to bound. `isExempt` now receives the request's headers as well as its URL, and `exemptMcpEndpoint` refuses to exempt anything sent as `application/x-www-form-urlencoded`, before any URL comparison. That is a discriminator rather than a heuristic: the token exchange, the refresh and revocation are all form-encoded per RFC 6749 §4.1.3 and RFC 7009 §2.1, and MCP is JSON-RPC over application/json and never is. Also records, on the no-deadline route test, what it does not catch: at a 400ms wait an unconditional timer restored at the production 30s budget would still leave the request pending and the test would pass. Catching that needs either a ~31s wait on every CI run or a route-level deadline knob existing only for the test, and a second source of truth for the deadline is the defect round 13 removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 113 +++++++++++++----- .../mcp/remote/server-extra-coverage.test.ts | 9 ++ core/auth/requestTimeout.ts | 51 +++++++- 3 files changed, 142 insertions(+), 31 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 6cd232e7..3fb90a68 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -498,12 +498,11 @@ describe("withOAuthRequestTimeout", () => { describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => { const SERVER = "https://srv.example.com/mcp"; - it("exempts the MCP endpoint itself, whatever method or query", () => { - const isExempt = exemptMcpEndpoint(() => SERVER); - - expect(isExempt(SERVER)).toBe(true); - expect(isExempt(`${SERVER}?sessionId=abc`)).toBe(true); - expect(isExempt(`${SERVER}#frag`)).toBe(true); + /** Most cases are plain JSON-RPC traffic, which carries no form media type. */ + const JSON_HEADERS = new Headers({ "content-type": "application/json" }); + /** The token family: RFC 6749 §4.1.3 and RFC 7009 §2.1 are form-encoded. */ + const FORM_HEADERS = new Headers({ + "content-type": "application/x-www-form-urlencoded;charset=UTF-8", }); /** Streamable HTTP: every MCP request goes to the configured URL. */ @@ -519,17 +518,46 @@ describe("withOAuthRequestTimeout", () => { () => true, ); + it("exempts the MCP endpoint itself, whatever method or query", () => { + const isExempt = exemptMcpEndpoint(() => SERVER); + + expect(isExempt(SERVER, JSON_HEADERS)).toBe(true); + expect(isExempt(`${SERVER}?sessionId=abc`, JSON_HEADERS)).toBe(true); + expect(isExempt(`${SERVER}#frag`, JSON_HEADERS)).toBe(true); + }); + + it("never exempts a form-encoded request, whatever URL it uses", () => { + // `oauthTokenUrl` takes any absolute URL, including the MCP endpoint's + // own — at which point a URL rule would exempt the transport-internal + // refresh, the one request this change exists to bound. The token family + // is form-encoded and MCP never is, so the media type settles it first. + for (const isExempt of [ + streamable(SERVER), + sse(SERVER), + exemptMcpEndpoint(() => SERVER), + ]) { + expect(isExempt(SERVER, FORM_HEADERS)).toBe(false); + } + // And a missing server URL no longer fails open past it either. + expect(exemptMcpEndpoint(() => undefined)(SERVER, FORM_HEADERS)).toBe( + false, + ); + }); + it("bounds same-origin OAuth work on a Streamable HTTP connection", () => { // The narrow rule, available because the endpoint is the configured URL. const isExempt = streamable(SERVER); - expect(isExempt(SERVER)).toBe(true); + expect(isExempt(SERVER, JSON_HEADERS)).toBe(true); expect( isExempt( "https://srv.example.com/.well-known/oauth-protected-resource/mcp", + JSON_HEADERS, ), ).toBe(false); - expect(isExempt("https://srv.example.com/token")).toBe(false); + expect(isExempt("https://srv.example.com/token", JSON_HEADERS)).toBe( + false, + ); }); it("widens to the origin on legacy SSE, whose message path is unknowable", () => { @@ -538,9 +566,14 @@ describe("withOAuthRequestTimeout", () => { const isExempt = sse("https://srv.example.com/sse"); expect( - isExempt("https://srv.example.com/.well-known/openid-configuration"), + isExempt( + "https://srv.example.com/.well-known/openid-configuration", + JSON_HEADERS, + ), ).toBe(true); - expect(isExempt("https://as.example.com/token")).toBe(false); + expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe( + false, + ); }); it("falls back to the origin rule when the transport is unknown", () => { @@ -554,8 +587,8 @@ describe("withOAuthRequestTimeout", () => { }, ); - expect(noHint("https://srv.example.com/token")).toBe(true); - expect(throws("https://srv.example.com/token")).toBe(true); + expect(noHint("https://srv.example.com/token", JSON_HEADERS)).toBe(true); + expect(throws("https://srv.example.com/token", JSON_HEADERS)).toBe(true); }); it("exempts legacy SSE's separate message endpoint", () => { @@ -567,22 +600,34 @@ describe("withOAuthRequestTimeout", () => { // an origin rule exact rather than approximate. const isExempt = exemptMcpEndpoint(() => "https://srv.example.com/sse"); - expect(isExempt("https://srv.example.com/messages?sessionId=abc")).toBe( - true, - ); - expect(isExempt("https://srv.example.com/")).toBe(true); + expect( + isExempt( + "https://srv.example.com/messages?sessionId=abc", + JSON_HEADERS, + ), + ).toBe(true); + expect(isExempt("https://srv.example.com/", JSON_HEADERS)).toBe(true); }); it("bounds OAuth work on a different origin", () => { const isExempt = exemptMcpEndpoint(() => SERVER); - expect(isExempt("https://as.example.com/token")).toBe(false); + expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe( + false, + ); expect( - isExempt("https://as.example.com/.well-known/openid-configuration"), + isExempt( + "https://as.example.com/.well-known/openid-configuration", + JSON_HEADERS, + ), ).toBe(false); // Origin is scheme + host + port, so any of the three differing bounds it. - expect(isExempt("http://srv.example.com/token")).toBe(false); - expect(isExempt("https://srv.example.com:8443/token")).toBe(false); + expect(isExempt("http://srv.example.com/token", JSON_HEADERS)).toBe( + false, + ); + expect(isExempt("https://srv.example.com:8443/token", JSON_HEADERS)).toBe( + false, + ); }); it("fails open when the server URL is unknown or unparseable", () => { @@ -590,23 +635,37 @@ describe("withOAuthRequestTimeout", () => { // severs a long-running tool call, while failing to bound leaves the // SDK's own per-request timeout as the backstop it already was. expect( - exemptMcpEndpoint(() => undefined)("https://as.example.com/token"), + exemptMcpEndpoint(() => undefined)( + "https://as.example.com/token", + JSON_HEADERS, + ), ).toBe(true); - expect(exemptMcpEndpoint(() => "")("https://as.example.com/token")).toBe( + expect( + exemptMcpEndpoint(() => "")( + "https://as.example.com/token", + JSON_HEADERS, + ), + ).toBe(true); + expect(exemptMcpEndpoint(() => "not a url")(SERVER, JSON_HEADERS)).toBe( + true, + ); + expect(exemptMcpEndpoint(() => SERVER)("not a url", JSON_HEADERS)).toBe( true, ); - expect(exemptMcpEndpoint(() => "not a url")(SERVER)).toBe(true); - expect(exemptMcpEndpoint(() => SERVER)("not a url")).toBe(true); }); it("reads the server URL per call, since it changes between connects", () => { let current = SERVER; const isExempt = exemptMcpEndpoint(() => current); - expect(isExempt("https://other.example.com/mcp")).toBe(false); + expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe( + false, + ); current = "https://other.example.com/mcp"; - expect(isExempt("https://other.example.com/mcp")).toBe(true); - expect(isExempt(SERVER)).toBe(false); + expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe( + true, + ); + expect(isExempt(SERVER, JSON_HEADERS)).toBe(false); }); }); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 96e3bd15..7d2fea1f 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -505,6 +505,15 @@ describe("server.ts supplemental coverage", () => { // a Streamable HTTP tool call that legitimately withholds its response // headers — reintroducing on the backend exactly what `exemptMcpEndpoint` // prevents on the client, and reporting it as an OAuth timeout besides. + // + // ⚠️ What it does NOT catch, stated so nobody reads more into it: the + // wait below is 400ms, so an unconditional timer restored at the + // production 30s budget would still leave the request pending here and + // the test would pass (Copilot). Catching that needs either a ~31s wait + // on every CI run or a route-level deadline knob existing only for the + // test — and a second source of truth for the deadline is the defect + // round 13 removed. Measured, this fails against an unconditional timer + // at any budget shorter than the wait. const caller = new AbortController(); const pending = fetch(`${h.baseUrl}/api/fetch`, { method: "POST", diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 46943308..5a963fe8 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -198,6 +198,15 @@ function readNegotiatesEndpoint(hint: (() => boolean) | undefined): boolean { } } +/** + * The media type every OAuth token-family request uses — the token exchange, + * the refresh, and revocation are all `application/x-www-form-urlencoded` per + * RFC 6749 §4.1.3 and RFC 7009 §2.1. MCP is JSON-RPC over `application/json` + * and never uses it, which is what makes this a safe discriminator rather than + * a heuristic. + */ +const OAUTH_FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"; + export function exemptMcpEndpoint( getServerUrl: () => string | undefined, /** @@ -206,8 +215,22 @@ export function exemptMcpEndpoint( * connects. Absent, or throwing, is treated as `true`. */ negotiatesEndpoint?: () => boolean, -): (url: string) => boolean { - return (url) => { +): (url: string, headers: Headers) => boolean { + return (url, headers) => { + // A URL match is not by itself proof of MCP traffic. `oauthTokenUrl` takes + // any absolute URL, so a user may point it at the MCP endpoint's own URL, + // and the transport-internal refresh then travels this chain to a path the + // rules below would exempt — leaving the one request this whole change + // exists to bound running unbounded (Copilot). The token family is + // form-encoded and MCP never is, so the media type settles it before any + // URL comparison happens. + if ( + (headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() === + OAUTH_FORM_MEDIA_TYPE + ) { + return false; + } + const serverUrl = getServerUrl(); if (!serverUrl) return true; const negotiated = readNegotiatesEndpoint(negotiatesEndpoint); @@ -255,6 +278,21 @@ export function deadlineForRequestInit( return init ? REQUEST_DEADLINES.get(init) : undefined; } +/** + * The request's headers, whatever form `fetch`'s arguments took. `init` wins + * over a `Request`'s own, matching `fetch`. + */ +function requestHeadersOf( + input: RequestInfo | URL, + init: RequestInit | undefined, +): Headers { + if (init?.headers) return new Headers(init.headers); + if (typeof input !== "string" && !(input instanceof URL)) { + return new Headers(input.headers); + } + return new Headers(); +} + /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; @@ -359,6 +397,9 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { * predicate because every request on it is OAuth work, while the transport * chain passes `exemptMcpEndpoint`. * + * `isExempt` receives the request's URL **and headers**, because a URL alone + * cannot always tell the two kinds of traffic apart — see `exemptMcpEndpoint`. + * * `isExempt` lets one wrapped fetch serve a mixed chain. It is how the * *transport* fetch can be bounded at all: that chain carries both the SDK's * OAuth work and the MCP traffic itself, and the MCP traffic must not be @@ -382,7 +423,7 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { export function withOAuthRequestTimeout( fetchFn: typeof fetch, timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, - isExempt?: (url: string) => boolean, + isExempt?: (url: string, headers: Headers) => boolean, ): typeof fetch { // Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one // calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on @@ -405,7 +446,9 @@ export function withOAuthRequestTimeout( // An exempt request is passed through completely untouched — no deadline, // no signal of ours, and crucially no body buffering, since the responses // this exists for are streams that must stay open (see `isExempt` above). - if (isExempt?.(url)) return fetchFn(input, init); + if (isExempt?.(url, requestHeadersOf(input, init))) { + return fetchFn(input, init); + } const controller = new AbortController(); const callerSignal = callerSignalOf(input, init); From bb01c1e73b8558db628d14c78d58a946474738c9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 07:25:56 -0400 Subject: [PATCH 026/120] fix: bound the body by draining a clone, not by rebuilding (#2319 round 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilding a Response around the buffered bytes could never be faithful. `url`, `redirected` and `type` are internal slots, so shadowing them as own properties satisfied a direct read and was lost the moment a caller called clone() — which returns a fresh native Response. The headers guard differed too, and the decoded bytes made the inherited content-encoding and content-length wrong, so those had to be stripped: four observable differences from the response fetch actually produced. The wrapper now drains `response.clone()` under the deadline and returns the original. Cloning tees the body, so draining one branch buffers the other: the bound is unchanged, the caller reads without a second trip, and every native slot stays intact. rebuildResponse and its null-body handling are gone, and with them the whole class. Also: getServerUrl() throws for a non-HTTP configuration, and this wrapped fetch can still be handed to a transport or a custom factory that calls it — so the documented fail-open was reachable only until that getter threw, at which point a configuration error propagated out of a fetch. Caught, for the same reason readNegotiatesEndpoint catches. Tests: response identity, the body still readable after the drain, a re-clone keeping its metadata (the case a rebuild could not satisfy), content-encoding preserved because nothing invalidates it now, a null-body status passed through, and the throwing getter failing open while still not slipping past the form-encoded check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 138 ++++++++++-------- core/auth/requestTimeout.ts | 99 +++++-------- 2 files changed, 110 insertions(+), 127 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 3fb90a68..832a147b 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -266,30 +266,12 @@ describe("withOAuthRequestTimeout", () => { expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); }); - it("returns a readable response whose metadata survives the rebuild", async () => { + it("hands back the original response, not a copy", async () => { const original = new Response('{"issuer":"https://as.example.com"}', { status: 201, statusText: "Created", headers: { "content-type": "application/json", "x-probe": "kept" }, }); - // Seeded to NON-default values, which is the whole point of the case: a - // synthetic `new Response(...)` already has `url === ""`, `redirected === - // false` and `type === "default"` — exactly the rebuilt response's own - // defaults — so asserting against those would stay green with the - // `Object.defineProperty` loop deleted from `rebuildResponse` (Copilot). - // These are read-only getters, hence the same mechanism the source uses. - Object.defineProperty(original, "url", { - value: "https://as.example.com/redirected", - configurable: true, - }); - Object.defineProperty(original, "redirected", { - value: true, - configurable: true, - }); - Object.defineProperty(original, "type", { - value: "cors", - configurable: true, - }); const wrapped = withOAuthRequestTimeout( vi.fn().mockResolvedValue(original), 1000, @@ -297,68 +279,84 @@ describe("withOAuthRequestTimeout", () => { const response = await wrapped(URL_UNDER_TEST); - expect(response.status).toBe(201); - expect(response.statusText).toBe("Created"); - expect(response.ok).toBe(true); - expect(response.headers.get("x-probe")).toBe("kept"); - expect(response.headers.get("content-encoding")).toBeNull(); - expect(response.headers.get("content-length")).toBeNull(); - expect(response.url).toBe("https://as.example.com/redirected"); - expect(response.redirected).toBe(true); - expect(response.type).toBe("cors"); - // The rebuild must not change what enumerates. Which keys a `Response` owns - // is the host's business — under the Fetch standard these three are - // prototype getters and a native response has no own enumerable keys, while - // happy-dom makes them own enumerable data properties — so this compares - // against a baseline built in the same runtime rather than asserting either - // answer. Hard-coding one would make the rebuilt response observably - // different from a response that never passed through the wrapper, in - // `Object.keys`, object spread and `JSON.stringify`. - const baseline = Object.keys(new Response("{}")).sort(); - expect(Object.keys(response).sort()).toEqual(baseline); + // Identity, which is the strongest form of "nothing was changed": every + // native slot, the headers guard and `clone()` behave as they would if this + // wrapper were not in the chain at all. + expect(response).toBe(original); await expect(response.json()).resolves.toEqual({ issuer: "https://as.example.com", }); }); - it("drops content-encoding and content-length, which the buffer invalidates", async () => { - // `arrayBuffer()` hands back the *decoded* entity body, so an inherited - // `content-encoding` would describe an encoding the bytes no longer have - // and the inherited `content-length` would be the compressed size. Both are - // wrong, and both show up in the Network capture. - const decoded = '{"issuer":"https://as.example.com"}'; + it("leaves the body readable after draining the clone", async () => { + // The bound works by draining a clone; teeing buffers the other branch, so + // the caller must still be able to read the original without a second trip. const wrapped = withOAuthRequestTimeout( - vi.fn().mockResolvedValue( - new Response(decoded, { - headers: { - "content-type": "application/json", - "content-encoding": "gzip", - "content-length": "42", - }, - }), - ), + vi.fn().mockResolvedValue(new Response("hello")), 1000, ); const response = await wrapped(URL_UNDER_TEST); - expect(response.headers.get("content-encoding")).toBeNull(); - expect(response.headers.get("content-length")).toBeNull(); - expect(response.headers.get("content-type")).toBe("application/json"); - await expect(response.text()).resolves.toBe(decoded); + expect(response.bodyUsed).toBe(false); + await expect(response.text()).resolves.toBe("hello"); }); - it("rebuilds a null-body status without handing it a body", async () => { - // `new Response(body, { status: 204 })` throws; the rebuild has to pass null. + it("leaves the caller free to clone it again, with metadata intact", async () => { + // The case a rebuilt response could never satisfy: `url` / `redirected` / + // `type` are internal slots, so a shadowed own property survives a direct + // read and is lost the moment anyone calls `clone()`. + const original = new Response("{}", { status: 200 }); + Object.defineProperty(original, "url", { + value: "https://as.example.com/redirected", + configurable: true, + }); + Object.defineProperty(original, "redirected", { + value: true, + configurable: true, + }); const wrapped = withOAuthRequestTimeout( - vi - .fn() - .mockResolvedValue(new Response(null, { status: 204 })), + vi.fn().mockResolvedValue(original), + 1000, + ); + + const response = await wrapped(URL_UNDER_TEST); + const copy = response.clone(); + + expect(copy.url).toBe("https://as.example.com/redirected"); + expect(copy.redirected).toBe(true); + await expect(copy.json()).resolves.toEqual({}); + }); + + it("keeps content-encoding and content-length, which are the server's own", async () => { + // Nothing is rebuilt, so nothing invalidates them: the caller gets the + // response `fetch` produced and decodes it the way `fetch` intends. + const original = new Response("{}", { + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + }, + }); + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue(original), 1000, ); const response = await wrapped(URL_UNDER_TEST); + expect(response.headers.get("content-encoding")).toBe("gzip"); + }); + + it("passes a null-body status through untouched", async () => { + const original = new Response(null, { status: 204 }); + const wrapped = withOAuthRequestTimeout( + vi.fn().mockResolvedValue(original), + 1000, + ); + + const response = await wrapped(URL_UNDER_TEST); + + expect(response).toBe(original); expect(response.status).toBe(204); expect(response.body).toBeNull(); }); @@ -654,6 +652,22 @@ describe("withOAuthRequestTimeout", () => { ); }); + it("fails open when the server-URL getter throws", () => { + // `InspectorClient.getServerUrl()` throws for a non-HTTP configuration, + // and this wrapped fetch can still be handed to a transport or a custom + // factory that calls it — propagating a configuration error out of a + // fetch would turn the documented fail-open into a hard failure. + const isExempt = exemptMcpEndpoint(() => { + throw new Error("Server URL is only available for HTTP transports"); + }); + + expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(true); + // Still not past the form-encoded check, which runs first. + expect(isExempt("https://as.example.com/token", FORM_HEADERS)).toBe( + false, + ); + }); + it("reads the server URL per call, since it changes between connects", () => { let current = SERVER; const isExempt = exemptMcpEndpoint(() => current); diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 5a963fe8..3ccca28d 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -26,8 +26,9 @@ * resolves as soon as response headers arrive, so a server that sends headers * — or half a JSON document — and then stalls would leave the caller's * `response.json()` hanging with nothing watching it. Every response on this - * path is a small finite document, so the body is buffered under the same race - * and the response rebuilt around it. + * path is a small finite document, so a *clone* of it is drained under the same + * race and the original — untouched, with every native slot intact — is what + * the caller gets. * * Today the common case is bounded, but only incidentally: a discovery stall * that happens to sit inside an SDK `initialize` rides that request's own @@ -231,7 +232,17 @@ export function exemptMcpEndpoint( return false; } - const serverUrl = getServerUrl(); + // `getServerUrl()` throws for a non-HTTP configuration, and this wrapped + // fetch can be handed to a transport or a custom factory that calls it + // anyway — propagating a configuration error out of a fetch would turn the + // documented fail-open into a hard failure (Copilot). Caught here for the + // same reason `readNegotiatesEndpoint` catches. + let serverUrl: string | undefined; + try { + serverUrl = getServerUrl(); + } catch { + return true; + } if (!serverUrl) return true; const negotiated = readNegotiatesEndpoint(negotiatesEndpoint); try { @@ -326,64 +337,6 @@ export function callerSignalOf( return undefined; } -/** - * Statuses the Fetch Standard defines as null-body. Handing the `Response` - * constructor a body with one of these throws a `TypeError`, so the rebuilt - * response below must pass `null` instead. - */ -const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); - -/** - * Rebuild a `Response` around an already-buffered body. - * - * Two things have to be carried across by hand. - * - * `url`, `redirected` and `type` are getters with no constructor option, and - * callers do read them — the RFC 8414/OIDC compat wrapper stamps a header - * naming the URL a body came from, and the SDK's discovery reports the - * responding URL in its errors. So they are copied explicitly rather than - * silently reset to `""` / `false` / `"default"`. - * - * `content-encoding` and `content-length`, by contrast, must be *dropped*. - * `arrayBuffer()` hands back the **decoded** entity body, so a gzipped metadata - * document arrives here as plain JSON bytes: an inherited `content-encoding` - * would describe an encoding the body no longer has, and the inherited - * `content-length` would be the compressed size — both of them wrong, and both - * visible in the Network capture (Copilot). `responseWithBody` in - * `endpointOverrides.ts` deletes the same two headers for the same reason. - */ -function rebuildResponse(response: Response, body: ArrayBuffer): Response { - const headers = new Headers(response.headers); - headers.delete("content-encoding"); - headers.delete("content-length"); - const rebuilt = new Response( - NULL_BODY_STATUSES.has(response.status) ? null : body, - { - status: response.status, - statusText: response.statusText, - headers, - }, - ); - for (const key of ["url", "redirected", "type"] as const) { - // Enumerability is taken from whatever the freshly built `Response` already - // has for this key, rather than asserted. Hard-coding either answer makes - // the rebuilt response observably different from a native one — and which - // answer is right is the host's business: under the Fetch standard (and - // undici) these are prototype getters with no own enumerable key at all, - // while happy-dom defines them as own enumerable data properties. Copying - // the descriptor keeps `Object.keys`, object spread and `JSON.stringify` - // identical to a response that never passed through here, in both - // (Copilot). `false` for the getter case, where there is no own descriptor. - const existing = Object.getOwnPropertyDescriptor(rebuilt, key); - Object.defineProperty(rebuilt, key, { - value: response[key], - enumerable: existing?.enumerable ?? false, - configurable: true, - }); - } - return rebuilt; -} - /** * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. * @@ -417,8 +370,9 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response { * a fetch that drops the signal. This wrapper only ever *adds* a reason to give * up; it never removes the caller's. * - * The response **body** is buffered under the same deadline, because `fetch` - * resolves on headers and a stalled body would otherwise be unbounded. + * The response **body** is drained through a clone under the same deadline, + * because `fetch` resolves on headers and a stalled body would otherwise be + * unbounded. The caller receives the original response, not a copy. */ export function withOAuthRequestTimeout( fetchFn: typeof fetch, @@ -512,8 +466,23 @@ export function withOAuthRequestTimeout( // not optional on a mixed chain: an exempt request skips this buffering // entirely, which is what lets an SSE body stay open rather than being // drained into memory here. - const body = await Promise.race([response.arrayBuffer(), abandoned]); - return rebuildResponse(response, body); + // Read a *clone* under the deadline and hand back the original. Cloning + // tees the body, so draining one branch buffers the other: by the time + // this resolves the original is fully in memory and the caller reads it + // without touching the network again — the bound is the same, and the + // response the caller gets is the one `fetch` produced. + // + // The alternative, rebuilding a `Response` around the buffered bytes, + // could never be faithful (Copilot). `url`, `redirected` and `type` are + // internal slots; shadowing them as own properties satisfies a direct + // read and is lost again the moment a caller calls `clone()`, which + // returns a fresh native `Response`. The headers guard differs too, and + // the decoded bytes made the inherited `content-encoding` and + // `content-length` wrong, so those had to be stripped — a rebuilt + // response was observably not the original in at least four ways. Not + // rebuilding removes the whole class. + await Promise.race([response.clone().arrayBuffer(), abandoned]); + return response; } catch (err) { // `controller.abort()` above can reject the underlying fetch *before* // `reject` runs, in which case the race settles with undici's From 45d87911ebec180d8151dfc35b6886ee3f47fdad Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 07:42:17 -0400 Subject: [PATCH 027/120] fix: release the discarded discovery response on every throw (#2319 round 22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout and cancellation exits added over the last few rounds drop the original failed discovery response, which nobody will ever receive — and an unread body holds its connection open on Node/undici. The successful-substitution path already released it for that reason; the five exceptional exits now do too (pre-abort, timeout, fetch-abort, post-release abort, body-read abort). Matters most for a standalone user of the exported wrapper, where no timeout wrapper underneath has already drained it. Also drops the last descriptions of the superseded rebuild: the test file's header, an inline comment asserting the response is "not the same object" when the file later asserts identity, and the PR description's account of copied metadata and deleted encoding headers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../core/auth/oidcDiscoveryCompat.test.ts | 35 +++++++++++++++++++ .../src/test/core/auth/requestTimeout.test.ts | 14 ++++---- core/auth/oidcDiscoveryCompat.ts | 33 ++++++++++++++--- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts index 511f307b..14096711 100644 --- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts +++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts @@ -543,6 +543,41 @@ describe("probe cancellation (#2319)", () => { expect(await settled).toBe(reason); }); + it("releases the discarded discovery response on an exceptional exit", async () => { + // Nobody receives `response` when the loop throws, and an unread body holds + // its connection open on Node/undici — the same reason the successful + // substitution path releases it. + const cancels = vi.fn<() => Promise>().mockResolvedValue(undefined); + const discovery = new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode("not found")); + }, + }), + { status: 404 }, + ); + // Spy on the body's cancel, which is what `releaseBody` calls. + const body = discovery.body; + if (body) vi.spyOn(body, "cancel").mockImplementation(cancels); + + const inner = vi.fn((input) => + String(input) === RFC8414 + ? Promise.resolve(discovery) + : new Promise(() => {}), + ); + const wrapped = withRfc8414OidcCompat(inner); + + const settled = wrapped(RFC8414, { + signal: AbortSignal.abort(new Error("gone")), + }).then( + () => "resolved", + () => "threw", + ); + + expect(await settled).toBe("threw"); + expect(cancels).toHaveBeenCalled(); + }); + it("still falls back to the original response on an ordinary probe failure", async () => { const inner = vi.fn((input) => { if (String(input) === RFC8414) { diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 832a147b..1875d878 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -7,10 +7,11 @@ * - **The bound itself**, including the *body*: `fetch` resolves on headers, so * a server that sends them and then stalls has to be caught by the buffering * race, not by the fetch promise. - * - **The rebuilt response.** Buffering means the caller gets a different - * `Response` object, so `url` / `redirected` / `type` are asserted to survive - * and `content-encoding` / `content-length` to be dropped — the buffer holds - * the decoded body, which makes both of those headers lies. + * - **The response's identity.** The bound drains a *clone* and hands back the + * original, so the cases assert identity, that the body is still readable + * after the drain, and that a caller's own `clone()` keeps its metadata — + * the property a rebuilt response could not have, since `url` / `redirected` + * / `type` are internal slots. * - **Cancellation semantics.** The caller's signal is forwarded *and* raced, * so the tests distinguish the two: one uses a fetch that honours the signal, * another uses one that ignores it entirely. @@ -44,8 +45,9 @@ afterEach(() => { describe("withOAuthRequestTimeout", () => { it("passes a prompt response through", async () => { - // Not the same object: the body is buffered under the deadline (see the - // stalled-body case below) and the response rebuilt around it. + // The same object: the body is drained through a clone under the deadline + // (see the stalled-body case below), so nothing about the response the + // caller receives changes. const inner = vi .fn() .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index 760b3540..d6ca43d3 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -271,7 +271,18 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // rejects immediately on a pre-aborted signal, so this is mostly about // not sending the request at all — the same reasoning as the // short-circuit in `withOAuthRequestTimeout`. - if (callerSignal?.aborted) throw callerSignal.reason; + // + // Every exceptional exit below releases `response` first. On a normal + // return it is the value the caller gets and reads; on a throw nobody + // will ever read it, and an unread body holds its connection open on + // Node/undici — the same reason the successful-substitution path + // releases it (Copilot). Matters most for a standalone user of this + // exported wrapper, where no timeout wrapper is underneath to have + // already drained it. + if (callerSignal?.aborted) { + await releaseBody(response); + throw callerSignal.reason; + } let probe: Response; try { @@ -287,13 +298,19 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // is one the SDK's own `buildDiscoveryUrls` emits, so a stall here is a // stall the SDK would have hit itself, unbounded. Everything else — a // CORS rejection, DNS, a reset — still falls back as documented above. - if (err instanceof OAuthRequestTimeoutError) throw err; + if (err instanceof OAuthRequestTimeoutError) { + await releaseBody(response); + throw err; + } // Likewise a caller that gave up: substituting the preceding 404 for // its own abort would report a discovery failure for a request it // deliberately cancelled. The caller's own `reason` is what surfaces, // here and at the two sites below, rather than whatever shape the // underlying layer happened to reject with. - if (callerSignal?.aborted) throw callerSignal.reason; + if (callerSignal?.aborted) { + await releaseBody(response); + throw callerSignal.reason; + } return response; } if (!probe.ok) { @@ -307,7 +324,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // the body is being discarded must not be answered with the preceding // response either, and `continue` would otherwise carry on probing for // a caller that has stopped waiting. - if (callerSignal?.aborted) throw callerSignal.reason; + if (callerSignal?.aborted) { + await releaseBody(response); + throw callerSignal.reason; + } if (continuesDiscovery(probe.status)) continue; return response; } @@ -331,7 +351,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // discovery failure in place of its own abort reason (Copilot). A body // that merely will not parse still falls through, which is the case // this catch exists for. - if (callerSignal?.aborted) throw callerSignal.reason; + if (callerSignal?.aborted) { + await releaseBody(response); + throw callerSignal.reason; + } return response; } if (!isRfc8414OnlyMetadata(parsed)) { From 1e501fa5cbaf28909c0982cf573019251035aa0b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 07:57:38 -0400 Subject: [PATCH 028/120] fix: redact sensitive query values from timeout diagnostics (#2319 round 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout message interpolated the raw request URL, and createFetchTracker records that message verbatim into the Network log and the persisted session. An OAuth endpoint's query string can carry a `code`, an `access_token` or a `client_secret` — exactly the fields fetchTracking already redacts everywhere else it records a URL — so a stalled signed endpoint would have written its secret to both. Redacted with the existing `redactUrlQuery`, in the OAuthRequestTimeoutError constructor rather than at each display site, so both the message and the public `url` field are covered by construction and no future consumer can reintroduce it. The route's own proxied-timeout message and the URL in its 504 marker get the same treatment, since both cross back to the browser and are recorded there. The path and every non-sensitive parameter survive, so the endpoint stays identifiable — which was the point of naming it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 30 +++++++++++++++++++ core/auth/requestTimeout.ts | 19 ++++++++++-- core/mcp/remote/node/server.ts | 10 +++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index 1875d878..c7cb609f 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -88,6 +88,36 @@ describe("withOAuthRequestTimeout", () => { expect(timeout.timeoutMs).toBe(1000); }); + it("redacts sensitive query values from the endpoint it names", async () => { + vi.useFakeTimers(); + // This message is recorded verbatim by `createFetchTracker` into the + // Network log and the persisted session, and an OAuth endpoint's query + // string can carry a `code`, an `access_token` or a `client_secret` — + // which `fetchTracking` already redacts everywhere else it records a URL. + const sensitive = + "https://as.example.com/token?code=abc123&client_secret=shhh&state=keepme"; + const wrapped = withOAuthRequestTimeout(neverSettles, 1000); + + const assertion = wrapped(sensitive).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + const err = (await assertion) as OAuthRequestTimeoutError; + + for (const secret of ["abc123", "shhh"]) { + expect(err.message).not.toContain(secret); + expect(err.url).not.toContain(secret); + } + // Still identifiable, which was the point of naming the endpoint: the path + // and every non-sensitive parameter survive. Read through `URL`, since + // `URLSearchParams.toString()` percent-encodes the redaction marker. + const redacted = new URL(err.url); + expect(redacted.origin + redacted.pathname).toBe( + "https://as.example.com/token", + ); + expect(redacted.searchParams.get("state")).toBe("keepme"); + expect(redacted.searchParams.get("code")).toBe("[REDACTED]"); + expect(redacted.searchParams.get("client_secret")).toBe("[REDACTED]"); + }); + it("takes the URL off a Request object too", async () => { vi.useFakeTimers(); const wrapped = withOAuthRequestTimeout(neverSettles, 1000); diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 3ccca28d..ba06e53f 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -63,6 +63,8 @@ * halves the 60s the SDK handshake incidentally provides, and it is the only * bound at all on the legs that sit outside an SDK request. */ +import { redactUrlQuery } from "../mcp/fetchTracking.js"; + export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; /** @@ -73,13 +75,26 @@ export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; * hosts, and which of them stalled is the whole diagnostic. */ export class OAuthRequestTimeoutError extends Error { + /** + * The endpoint that stalled, with sensitive query values redacted. + * + * Redacted in the constructor rather than at each display site, so every + * consumer is covered by construction: this error's message is recorded + * verbatim by `createFetchTracker` into the Network log and the persisted + * session, and an OAuth endpoint can carry a `code`, an `access_token` or a + * `client_secret` in its query string — which `fetchTracking` already + * deliberately redacts everywhere else it records a URL (Copilot). The path + * and every non-sensitive parameter survive, so the endpoint stays + * identifiable, which was the point of naming it. + */ readonly url: string; readonly timeoutMs: number; constructor(url: string, timeoutMs: number) { - super(`OAuth request to ${url} timed out after ${timeoutMs}ms`); + const safeUrl = redactUrlQuery(url); + super(`OAuth request to ${safeUrl} timed out after ${timeoutMs}ms`); this.name = "OAuthRequestTimeoutError"; - this.url = url; + this.url = safeUrl; this.timeoutMs = timeoutMs; } } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 62819249..ccdc7a8b 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -29,6 +29,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; import { createProxyFetch } from "../../node/proxyFetch.js"; import { OAUTH_TIMEOUT_WIRE_CODE } from "../../../auth/requestTimeout.js"; +import { redactUrlQuery } from "../../fetchTracking.js"; import type { RemoteConnectRequest, RemoteSendRequest, @@ -1266,6 +1267,11 @@ export function createRemoteApp( Number.isFinite(requestedTimeoutMs) ? Math.min(2_147_483_647, Math.max(0, Math.round(requestedTimeoutMs))) : undefined; + // Redacted for the same reason `OAuthRequestTimeoutError` redacts: this + // message and this URL are handed back to the browser, recorded in the + // Network log and persisted, and an OAuth endpoint's query string can carry + // a `code`, an `access_token` or a `client_secret` (Copilot). + const safeUrl = redactUrlQuery(url); const timer = deadlineMs === undefined ? undefined @@ -1273,7 +1279,7 @@ export function createRemoteApp( deadlineFired = true; controller.abort( new Error( - `proxied request to ${url} timed out after ${deadlineMs}ms`, + `proxied request to ${safeUrl} timed out after ${deadlineMs}ms`, ), ); }, deadlineMs); @@ -1338,7 +1344,7 @@ export function createRemoteApp( { error: msg, code: OAUTH_TIMEOUT_WIRE_CODE, - url, + url: safeUrl, timeoutMs: deadlineMs, }, 504, From 78f82475c494fa9a120cdfbc5db83bf7c7dadcc3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 08:19:57 -0400 Subject: [PATCH 029/120] fix: stop the drain and release both tee branches on timeout (#2319 round 24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Losing the race rejected the caller but did not stop the read. Because clone() tees the body, a drain still running kept pulling bytes AND kept buffering them for the unread original branch — unbounded memory on exactly the signal-ignoring body the race exists to contain. The clone is now drained through an explicit reader rather than clone().arrayBuffer(), precisely so the losing side has something it can cancel: arrayBuffer() locks the stream to an internal reader nothing else can reach. On any throw both branches are torn down, which is safe because that path never hands the response to a caller. The cancels are deliberately not awaited: cancelling one branch of a tee can itself wait on the other, which would hold the timeout open for as long as the stall it is meant to end. Each carries its own catch, so a stream already cancelled, errored or locked elsewhere does not mask the timeout the caller actually needs — covered by a test whose stream throws from cancel(). Verified by mutation: removing the teardown makes the new test fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../src/test/core/auth/requestTimeout.test.ts | 72 +++++++++++++++++++ core/auth/requestTimeout.ts | 55 +++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/auth/requestTimeout.test.ts b/clients/web/src/test/core/auth/requestTimeout.test.ts index c7cb609f..5f5b0465 100644 --- a/clients/web/src/test/core/auth/requestTimeout.test.ts +++ b/clients/web/src/test/core/auth/requestTimeout.test.ts @@ -298,6 +298,78 @@ describe("withOAuthRequestTimeout", () => { expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); }); + it("releases both tee branches when the deadline wins", async () => { + vi.useFakeTimers(); + // Losing the race rejects the caller; on its own it does not stop the read. + // A drain still running would keep pulling bytes *and* keep buffering them + // for the unread original branch — unbounded memory on exactly the + // signal-ignoring body the race exists to contain. Observed through the + // stream's own `cancel`, which is what tearing the branches down calls. + let cancelled = false; + const stalled = new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode('{"issuer":')); + }, + // Never resolves: headers arrived, the body never finishes. + pull() { + return new Promise(() => {}); + }, + cancel() { + cancelled = true; + }, + }); + const wrapped = withOAuthRequestTimeout( + vi + .fn() + .mockResolvedValue( + new Response(stalled, { headers: { "content-type": "text/plain" } }), + ), + 1000, + ); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + // The cancels are deliberately not awaited — cancelling one branch of a tee + // can wait on the other — so let them settle before observing. + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(cancelled).toBe(true); + }); + + it("still reports the timeout when tearing the branches down fails", async () => { + vi.useFakeTimers(); + // Cancelling a stream that is already errored, locked elsewhere, or whose + // source rejects is not a failure here — the cleanup runs on a path that is + // already rejecting with something the caller cares about far more. + const hostile = new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode("x")); + }, + pull() { + return new Promise(() => {}); + }, + cancel() { + throw new Error("cancel blew up"); + }, + }); + const wrapped = withOAuthRequestTimeout( + vi + .fn() + .mockResolvedValue( + new Response(hostile, { headers: { "content-type": "text/plain" } }), + ), + 1000, + ); + + const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err); + await vi.advanceTimersByTimeAsync(1000); + + expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError); + // And no unhandled rejection escapes from the cleanup. + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + it("hands back the original response, not a copy", async () => { const original = new Response('{"issuer":"https://as.example.com"}', { status: 201, diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index ba06e53f..7740b58c 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -319,6 +319,44 @@ function requestHeadersOf( return new Headers(); } +/** + * Pull a reader to completion, discarding what it yields. An absent reader — a + * null-body response — is already complete. + * + * Read through an explicit reader rather than `clone().arrayBuffer()` so the + * losing side of the race has something it can cancel: `arrayBuffer()` locks + * the stream to an internal reader nothing else can reach. + */ +async function drainToEnd( + reader: ReadableStreamDefaultReader | undefined, +): Promise { + if (!reader) return; + for (;;) { + const { done } = await reader.read(); + if (done) return; + } +} + +/** + * Tear down both branches of the tee. + * + * Deliberately **not awaited**, and the `void`s say which of the documented + * cases this is: the callee owns its failures (each carries its own `catch`), + * and the caller genuinely cannot await — cancelling one branch of a tee can + * itself wait on the other, which would hold the timeout open for exactly as + * long as the stall it is meant to end. Best-effort on each besides: a stream + * already cancelled, errored or locked elsewhere is not a failure here, and + * this runs on a path that is already rejecting with something the caller cares + * about more. + */ +function cancelBoth( + reader: ReadableStreamDefaultReader | undefined, + response: Response | undefined, +): void { + void reader?.cancel().catch(() => {}); + void response?.body?.cancel().catch(() => {}); +} + /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; @@ -466,11 +504,16 @@ export function withOAuthRequestTimeout( const nextInit: RequestInit = { ...init, signal }; REQUEST_DEADLINES.set(nextInit, budget); + // Held so the losing path can tear both tee branches down — see below. + let settled: Response | undefined; + let drainReader: ReadableStreamDefaultReader | undefined; + try { const response = await Promise.race([ fetchFn(input, nextInit), abandoned, ]); + settled = response; // `fetch` resolves once the response *headers* arrive, so stopping here // would leave the body unbounded: an authorization server can send // headers, or half a JSON document, and then stall, and the caller's @@ -496,9 +539,19 @@ export function withOAuthRequestTimeout( // `content-length` wrong, so those had to be stripped — a rebuilt // response was observably not the original in at least four ways. Not // rebuilding removes the whole class. - await Promise.race([response.clone().arrayBuffer(), abandoned]); + const copy = response.clone(); + drainReader = copy.body?.getReader(); + await Promise.race([drainToEnd(drainReader), abandoned]); return response; } catch (err) { + // ⚠️ Losing the race rejects the caller; on its own it does not stop the + // read. `clone()` tees the body, so a drain still running would keep + // pulling bytes *and* keep buffering them for the unread original branch + // — unbounded memory on exactly the signal-ignoring body the race exists + // to contain (Copilot). Both branches are cancelled here, which is safe + // precisely because this path throws: the caller never receives the + // response, so nothing is left to read it. + cancelBoth(drainReader, settled); // `controller.abort()` above can reject the underlying fetch *before* // `reject` runs, in which case the race settles with undici's // `AbortError` instead of ours. Which of the two wins is an ordering From ab80221ce685a3ed44a313fb650e2ce404c12721 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 11 Sep 2026 08:22:16 -0400 Subject: [PATCH 030/120] chore: land the #2247 round-9 review findings on v2/main (#2322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: land the #2247 round-9 review findings on v2/main Copilot filed a ninth review round on #2247 after it had merged, so none of it reached v2/main. All five findings are addressed here. 1. The testing -> test-servers hand-off delivered the wrong half of the procedure. test-servers documented only the two-process manual path (server-composable.js --config .json plus a second Inspector process), but an integration test builds the server in-process from the @modelcontextprotocol/inspector-test-server API and owns its lifecycle. Adds a "Two ways to use a fixture — pick the right one first" section with the in-process recipe, the four mechanics that bite (one barrel, start/stop in afterEach, read started.url, era is a constructor option), and a pointer to the reference test; renames "Run one" to "Run one by hand (two processes)" so the manual path names itself. testing now says in the connecting bullet that an automated test does NOT spawn. 2. testing's heading asked "does the test need a real server?", which its own hand-rolled-server exception contradicts. It now asks whether the test uses a test-servers/ fixture. 3. "imports" was too broad a dependency test — test-server-scope.test.ts imports the package to unit-test scope middleware as a pure function and never starts a server. The condition is now "starts, spawns, or configures", with that test named as the counterexample. 4. docs/skill-authoring.md attributed the 33% -> 100% rise to imperative wording and placement independently. The #2247 edit changed both at once, so only the combination is established; the split into two levers is now labelled the plausible mechanism, not a measured result. 5. The CHAIN_THRESHOLD comment generalised "a well-shaped pointer clears 0.8" from one two-case run of that same multi-variable edit. It now reads as an existence proof about the ceiling, not a property of pointers in general. Also records the headline lesson where it can be found again: a green hand-off number proves skill B loaded, never that B answered the question — in AGENTS.md's chain-case rules and in the skill-authoring checklist. Docs, skill bodies and one comment only; no runtime code changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N Signed-off-by: cliffhall * fix: address Copilot review round 1 on #2264 The new section claimed two shapes and there are three — the automated stdio path was missing, and it is the repo's established pattern (getTestMcpServerCommand() into a stdio transport, used by five web integration files and the whole CLI suite). Telling callers an automated test "does not spawn" told them not to use it. - test-servers: "Two ways" -> "Three ways", with a table and a subsection for the spawned-stdio shape — the transport owns the child (disconnect, not stop()), no config can be selected because that entry point runs the stdio default, and it is still the build. Names a reference test for it. - test-servers: the build is no longer scoped to the manual path. All three shapes resolve test-servers/build/ — two through the barrel alias, one by running the emitted .js — so the staleness hazard is called out as applying to every shape, and `Build first` no longer opens by claiming the only reason is subprocess spawning. - testing: the "does not spawn" instruction is replaced by the two automated shapes and when to pick each. What they share, and what the hand-off actually needed to say, is that neither picks a showcase config. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N Signed-off-by: cliffhall * fix: address Copilot review round 2 on #2264 Round 1 fixed the missing stdio shape but kept the wrong axis. Cutting the taxonomy by "automated vs by hand" excluded the config-driven smokes — smoke-web-elicitation.mjs spawns server-composable.js --config directly, and lib/mcp-app-flow.mjs does it for smoke:web:app, smoke:web:tabs and pack:verify. Those are automated AND need every bit of the config and protocol-era guidance a person at two terminals does, so the skill was routing their authors away from it. - test-servers: the cut is now how the SERVER IS STOOD UP, stated as such. Third shape is "spawned composable HTTP", whose consumers are the smokes, pack:verify, and a person by hand — the config/era guidance belongs to the shape, whoever starts it. Table gains a Consumers column and loses the Who column that caused this. - test-servers: the stale "an automated test builds the server in-process instead" line under `Run one by hand` is gone (suppressed comment); that heading is now the same shape driven by a person. - testing: routes by what is driving — integration/CLI test to in-process HTTP, stdio-is-the-point to spawned stdio, and a smoke to the composable-config path with the era guidance it actually needs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N Signed-off-by: cliffhall * fix: address Copilot review round 3 on #2264 Round 2 replaced one wrong axis with a narrower one: it routed "a smoke" wholesale to the config-driven composable path, and smoke:cli does not use it. smoke-cli.mjs starts an in-process createTestServerHttp for the header round-trip (so it can read back what the CLI sent) and writes the built stdio entry into a --catalog for the connect checks; smoke:tui uses only the latter. Neither picks a config. - test-servers: consumers filled in per row — in-process HTTP gains CLI tests and smoke:cli, spawned stdio gains smoke:cli and smoke:tui, and the composable row is narrowed to the WEB smokes. A warning states the point directly: "a smoke" is not a shape, pick by the row. - test-servers: the stdio shape now covers naming the built entry in a catalog without connecting, which is what those two smokes do. - testing: same narrowing, plus the reason spelled out so the word "smoke" stops being a routing key. - testing: the dependency criterion said "starts, spawns, or configures", which excluded the build-only smoke:tui case documented directly above it — the skill both required and rejected the hand-off for it (suppressed comment). It now reads "starts, spawns, configures, or hands a built entry to the subject under test". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N Signed-off-by: cliffhall --------- Signed-off-by: cliffhall Co-authored-by: Claude Opus 5 (1M context) --- .claude/skills/test-servers/SKILL.md | 170 ++++++++++++++++++++++++++- .claude/skills/testing/SKILL.md | 47 +++++++- AGENTS.md | 6 + docs/skill-authoring.md | 36 ++++-- scripts/skill-eval.mjs | 9 +- 5 files changed, 250 insertions(+), 18 deletions(-) diff --git a/.claude/skills/test-servers/SKILL.md b/.claude/skills/test-servers/SKILL.md index 58da2bd5..5c252e1a 100644 --- a/.claude/skills/test-servers/SKILL.md +++ b/.claude/skills/test-servers/SKILL.md @@ -17,9 +17,171 @@ and what the broken build did — is [`docs/test-servers.md`](../../../docs/test-servers.md). This skill is how to run one. +## Three ways to use a fixture — pick the right one first + +A fixture is stood up in **one of three shapes**, and most of what follows is +about the third. Establish which one you are in before reading further, because +the showcase **config file and the protocol-era table** belong to that one alone. + +⚠️ **The cut is how the server is stood up, not who is driving.** Automated and +by-hand is the wrong axis: the composable-config shape has *both* kinds of +consumer, and a smoke that spawns it needs every bit of the config and era +guidance a person at two terminals does. + +| Shape | Server runs | Config file | Consumers | +| --- | --- | --- | --- | +| **In-process HTTP** | inside the test process, built from the API | none — options are constructor args | integration tests, CLI tests, **`smoke:cli`** | +| **Spawned stdio** | a child process the transport (or the binary under test) starts | none — the stdio fixture runs its default config | integration tests, the CLI suites, **`smoke:cli`**, **`smoke:tui`** | +| **Spawned composable HTTP** | a child process started with `--config` | **yes** — a showcase `--config .json` | the **web** smokes, `pack:verify`, **and** a person by hand | + +⚠️ **"A smoke" is not a shape** — `smoke:cli` uses the first two and `smoke:tui` +the second, while only the config-driven web smokes use the third. Pick by the +row, never by the caller's category. + +- **In-process HTTP — `createTestServerHttp`.** The caller *constructs* the + server and owns its lifecycle. No subprocess, no JSON config, no showcase + config to pick. This is the shape for anything needing HTTP/SSE, a specific + tool set, or the modern handler — and it is not test-only: `scripts/smoke-cli.mjs` + starts one in the smoke process so it can read back the headers the CLI sent. +- **Spawned stdio — `getTestMcpServerCommand()`.** The test hands the built + fixture's `{ command, args }` to a stdio transport (or to the built CLI), and + the transport spawns it. A subprocess *is* started, but **still no config + file**: that entry point runs the stdio server's default config, so there is + nothing to pick. Reach for it when stdio is the point (`InspectorClient` over + stdio, the CLI's out-of-process E2E suite) and the default tool set is enough. + A caller may also just *name* the built entry rather than connect to it — + `smoke:cli` and `smoke:tui` write it into a `--catalog` as + `{ type: "stdio", command: node, args: [] }` — which is still + this shape, and still the build. +- **Spawned composable HTTP — `server-composable.js --config .json`.** + Picking the showcase config and the protocol era applies **to this shape**, + whoever starts it. Two consumers, and they differ only in who runs the second + process: + - **A script.** `scripts/smoke-web-elicitation.mjs` spawns it directly; + `scripts/lib/mcp-app-flow.mjs` (`startMcpAppServer`) does it for + `smoke:web:app`, `smoke:web:tabs` and `pack:verify`. These are automated and + config-driven, and the whole of this skill applies to them. ⚠️ **Not every + smoke is here** — `smoke:cli` and `smoke:tui` use the two shapes above and + pick no config at all. + - **You, in a terminal**, with the Inspector in another — `Run one by hand` + below. + +⚠️ **The build applies to all three.** Every shape resolves +`test-servers/build/` — the two API-driven ones through the +`@modelcontextprotocol/inspector-test-server` alias, the composable one by +running the emitted `.js` directly — so `Build first` and its stale-build hazard +are **not** guidance for one path. Read that section whichever shape you are in. + +### Automated, in-process HTTP: build the server from the API + +```ts +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, +} from "@modelcontextprotocol/inspector-test-server"; + +let server: TestServerHttp | null = null; + +afterEach(async () => { + // Stop it even when the assertion threw, or the port leaks into the next test. + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } +}); + +it("…", async () => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("excluded-tools-test", "1.0.0"), + tools: [createEchoTool()], + // `modern: {}` opts the fixture into the 2026-07-28 handler; omit it for legacy. + }); + await started.start(); + server = started; + + // `started.url` is the bound URL — read it, never reconstruct it from a port. + // …connect an InspectorClient to it and assert. +}); +``` + +The reference test is +[`clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts`](../../../clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts) +— read it before writing a new one; it is the shape fixture-backed integration +tests follow when the server is built in-process. (Stdio-backed ones follow the +next subsection instead.) + +Four mechanics of this path: + +- **The factories come from one barrel.** `createTestServerHttp` / + `createTestServerStdio` build the server; the `create*Tool`, + `create*Resource` and `create*Prompt` fixtures in + `test-servers/src/test-server-fixtures.ts` populate it; + `createTestServerInfo` fills in `serverInfo`. Prefer an existing fixture + factory to hand-writing a `ToolDefinition` — that is what makes the fixture a + shared one. +- **`start()` then `stop()`, and `stop()` in an `afterEach`.** The server binds a + real port, so a test that throws before stopping leaks it into the rest of the + file. +- **Read `started.url`.** `createTestServerHttp` resolves through + `findAvailablePort()`, which walks upward when the port is taken, so an + assumed port is the same bug the two-process path has. +- **Era is a constructor option, not a config file.** `modern: {}` on the config + object selects the modern handler; the client side picks its own negotiation + (`eraToVersionNegotiation`). The showcase-config era table below does not + apply. + +⚠️ **The barrel is an alias to the BUILD, not to the source** — +`vitest.shared.mts` maps `@modelcontextprotocol/inspector-test-server` to +`test-servers/build/index.js`. So `Build first` applies to this path in full, +stale-build hazard included: an edit to `test-servers/src` that is not rebuilt +is invisible to an in-process test exactly as it is to a spawned one. + +### Automated, spawned stdio: hand over the command + +```ts +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; + +const { command, args } = getTestMcpServerCommand(); +const client = new InspectorClient( + { type: "stdio", command, args }, + { environment: { transport: createTransportNode } }, +); +await client.connect(); +// … afterEach → client.disconnect(), which is what stops the child. +``` + +`getTestMcpServerCommand()` returns `node `. +Three consequences: + +- **You do not own the process, the transport does.** There is no `start()` / + `stop()` pair — disconnecting the client is what reaps the child, so the + `afterEach` that matters is `client.disconnect()`. +- **No config is selected and none can be.** That entry point starts the stdio + server on its **default** config, so the showcase-config table and the + protocol-era guidance below do not apply. If the case needs a specific tool + set or the modern handler, it is an in-process HTTP test, not this. +- **It is still the build.** The path comes from the module's own resolved + location under the alias, so it is `test-servers/build/`, with the same + staleness hazard. + +The same command feeds the CLI's out-of-process E2E suite +(`clients/cli/__tests__/e2e.test.ts`), which spawns the built CLI *and* lets it +spawn the fixture. Reference tests for this shape: +`clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts` +and `clients/cli/__tests__/methods.test.ts`. + ## Build first -The servers are spawned as real subprocesses, so the build output must exist: +Every shape above resolves generated output — the in-process one imports the +barrel, which is **aliased to `test-servers/build/index.js`**, and the stdio and +composable-config ones run emitted `.js` as real subprocesses. So the build must +exist whichever one you are in: ```sh cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/ @@ -41,9 +203,11 @@ rm -rf test-servers/build The `.tsbuildinfo` is pinned inside `build/` so that clean actually invalidates the cache. -## Run one +## Run one by hand (two processes) -Two processes: the test server, then the Inspector. +This is the **spawned composable HTTP** shape from the section above, driven by +you rather than by a smoke script — the config and era guidance is the same +either way. Two processes: the test server, then the Inspector. ```sh # 1. The server, from the repo root, with the config you picked: diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 85c8caa5..62a52e5c 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -11,7 +11,7 @@ statements, functions, and branches.** That rule and the React/Mantine conventions live in [`AGENTS.md`](../../../AGENTS.md); this skill is where a test goes, how to run it, and how to clear the gate. -## Before you write it: does the test need a real server? +## Before you write it: does the test use a `test-servers/` fixture? **If it does, load the `test-servers` skill now — that is step one, before choosing a location or writing a line.** @@ -23,8 +23,35 @@ ways to depend on one, and they need different halves of that skill: - **It connects to a fixture.** An integration test that connects; an end-to-end test that connects; a smoke that drives a connected flow; a coverage gap only reachable over a real connection; reproducing a reported bug - against a server. These need the whole procedure — which showcase config, - which protocol era, and the staleness hazard. + against a server. These need the whole procedure — the staleness hazard, and + then whichever half matches how the server is stood up. + ⚠️ **Which shape you need depends on what is driving, and there are three.** + All three are the **"Three ways to use a fixture"** section of `test-servers`, + which names the entry point and a reference for each — read the right third: + - **An integration or CLI test → in-process HTTP.** + `createTestServerHttp(...)` / `.start()` / `.stop()`, with the test owning + the lifecycle. Use it when the case needs HTTP or SSE, a specific tool set, + or the modern handler (`modern: {}` is a constructor option). **No showcase + config and no era table apply.** + - **An integration or CLI test where stdio is the point → spawned stdio.** + `getTestMcpServerCommand()` handed to a stdio transport or to the built CLI, + which spawns it. A subprocess *is* started, but it runs the stdio fixture's + **default** config, so there is still nothing to pick — and nothing to + override, so if the case needs a specific tool set it is an in-process HTTP + test instead. + - **A config-driven web smoke, or `pack:verify` → spawned composable HTTP.** + `smoke:web:elicitation`, `smoke:web:app`, `smoke:web:tabs` and `pack:verify` + all spawn `server-composable.js --config .json`. **The showcase-config + and protocol-era guidance applies to you in full** — being automated does + not exempt a smoke from it. + + ⚠️ **"A smoke" is not a shape, so do not route by that word.** `smoke:cli` + uses the first two — an in-process `createTestServerHttp` for the header + round-trip, and the built stdio entry in a `--catalog` for the connect checks + — and `smoke:tui` uses the stdio entry alone. Only the web smokes above are + config-driven. Pick by what the caller actually stands up. + + What applies to all three is that section's build warning. ⚠️ **Connecting is a strong hint, not the rule.** A few integration tests deliberately hand-roll a JSON-RPC server because the composable fixture *cannot* produce what they assert on — `inspectorClient-malformed-list.test.ts` @@ -39,7 +66,19 @@ ways to depend on one, and they need different halves of that skill: ⚠️ **"A build ran" is not the dependency — using the artefact is.** `clients/web`'s `pretest` runs `test-servers:build` before *every* unit run, so the fixture is on disk for tests that never reference it. What counts is whether -the test imports, spawns, or points a config at it. +the test **starts, spawns, configures, or hands a built entry to the subject +under test**. That last clause is what covers `smoke:tui`, which drives no +transport at all and still depends on the fixture — see the build-only bullet +above. + +⚠️ **And *importing* the package is not the dependency either.** The barrel +exports plain functions as well as server factories, so a test can import from +it and never stand a server up — `src/test/core/mcp/test-server-scope.test.ts` +imports `createScopeCheckMiddleware` and friends to unit-test the scope +middleware as a pure function, with no `start()` anywhere in the file. None of +the procedure applies to it — no config, no era, no lifecycle — it is an +ordinary unit test that happens to import its subject from that package. Ask +whether a *server* runs, not whether the import line is present. So the condition does **not** hold when the test renders a component from fixture props, exercises a pure function or a parser, or is a smoke that touches diff --git a/AGENTS.md b/AGENTS.md index 4f09d1c1..e80ed76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 — diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 2f6f2851..8582c732 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -201,6 +201,17 @@ ever taken.** `test-servers` scores 5/5 on its own cases and every one of them asks for it by name; a skill only ever reached _through_ another would score a clean 100% while the hand-off silently never fired (#2204). +⚠️ **A green hand-off number is not evidence the hand-off is USEFUL.** The case +can observe one thing — that skill B was loaded. Whether B then answers the +question the prompt actually asked is outside what any `chain` case measures, in +either direction: B can be loaded and say nothing relevant, or say something +actively wrong for the caller that arrived through A. `testing` -> +`test-servers` scored 100% while `test-servers` documented only the two-process +manual path, so every model that followed the pointer from an *integration-test* +prompt was handed the wrong half of the procedure (#2264). So when a chain case +goes green, **read B's body as the caller who arrives through A** and check that +what it says fits that caller. The number cannot do that for you. + A chained case names the ordered skills one run should load: ```json @@ -285,8 +296,8 @@ license (Copilot). A strict bound of `1.0` is therefore unreachable and the harness rejects it up front rather than failing every case. 0.5 is a floor on **useful reliability for a second-hop load**, not a claim that -0.8 is out of reach — a well-shaped pointer clears it outright, at 100% in the -worked example below. Note that a *chain* bar of 0.8 would be a **strict** one +0.8 is out of reach — the one pointer measured after reshaping cleared it +outright, at 100% in the worked example below. Note that a *chain* bar of 0.8 would be a **strict** one (`> 0.8`, the comparison this threshold uses; the first-move 0.8 is the inclusive `>=`), so at `RUNS=5` only a clean 5/5 would pass it — 4/5 would not. What 0.5 buys is that the column keeps carrying signal across the *range* of pointer strengths a repo actually has: a hand-off is a noisier @@ -319,11 +330,18 @@ measured 100% / 80% on the full suite and 100% / 100% on a focused noise, well short of the 67 above.) The transferable part is that **a pointer is followed when it reads as an action -with a trigger, and skimmed when it reads as a fact.** #2202 found the same -lever on a *description*'s shape; this is it applied to a body. The corollary is -where to put one: the top of a body is read before the model knows it needs the -second skill, so a pointer that lives only there is a pointer it has already -scrolled past by the time it matters. +with a trigger, placed where the decision is actually made.** #2202 found the +same lever on a *description*'s shape; this is it applied to a body. + +⚠️ **Read that as ONE lever, not two.** The #2247 edit changed the wording *and* +the placement in the same revision, and only the combination was measured, so +the run establishes the pair — it does not license attributing the rise to +imperative phrasing alone, nor to repetition alone. The split into two levers is +the plausible reading of the mechanism, not a measured result: the top of a body +is read before the model knows it needs the second skill, so a pointer that +lives only there has plausibly been scrolled past by the time it matters. Taking +them apart would need two more runs — reword without moving, and move without +rewording — and nobody has paid for those. Until someone does, ship both. ⚠️ **Do not read a rise between two `RUNS=3` runs as an improvement.** One sample is 33 points there, and the two weak-pointer runs above (33% / 33% and @@ -452,7 +470,9 @@ break. hand-off has a `chain` case — a pointer between skills is otherwise measured by nothing at all, and a skill reached only that way scores a clean 100% on direct cases while the hand-off never fires. It does not count toward the - floor in 4. + floor in 4. **Then read the target's body as the caller arriving through that + pointer** — the case proves the load, never that what loads is the half that + caller needs (#2264). 6. `npm run verify:skills` passes and the listing is under budget. 7. `RUNS=5 npm run skills:eval` — the **whole** suite — is ≥80% on every first-move case, including the skills you did not touch, and the hand-off diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 780c09bb..ed7fc245 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -57,11 +57,14 @@ const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); // acceptable is a separate judgement rather than one inherited from a number // tuned for the other measurement. 0.5 is the weakest claim worth asserting — // the pointer is taken more often than not — and it is a floor on useful -// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. A -// well-shaped pointer clears 0.8 outright: the committed `testing` -> +// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. One +// well-shaped pointer has cleared 0.8 outright: the committed `testing` -> // `test-servers` cases measured 33% (RUNS=3) when `testing` merely classified // which work belonged to `test-servers`, and 100%/100% (RUNS=5) once #2247 -// reshaped that into an imperative step. (An intermediate build of that change +// reshaped that into an imperative step. That is ONE pointer over ONE edit that +// moved wording and placement together, so read it as an existence proof that +// the ceiling is above 0.8 — not as a general property of "well-shaped" +// pointers, and not as grounds for raising this floor (Copilot, #2264). (An intermediate build of that change // measured 100%/80%. That is NOT an example of clearing a 0.8 bar — this // threshold is compared strictly, so 80% would fail one — but it is worth // knowing as the residual noise still present at RUNS=5.) What 0.5 buys From b4c02865e4cca03feb5b28e97f0b54f335192f7d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 08:33:18 -0400 Subject: [PATCH 031/120] fix: do not await the release on a cancellation path (#2319 round 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReadableStream.cancel()` adopts the underlying source's cancel promise, which is permitted never to settle — so awaiting `releaseBody` while propagating a cancellation can hang the very thing it was meant to end. The five exceptional exits in the compat probe loop now start the release without waiting on it: the release is a courtesy to the connection pool, and the caller's abort or timeout must reach it regardless. The two non-exceptional awaits are unchanged. Same class as the tee teardown in round 24, one file over. Also moves the `redactUrlQuery` import above DEFAULT_OAUTH_REQUEST_TIMEOUT_MS's JSDoc, which it had split from the constant — so hover and API tooling see the doc again. Verified by mutation: awaiting the release makes the new test time out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../core/auth/oidcDiscoveryCompat.test.ts | 30 +++++++++++++++++++ core/auth/oidcDiscoveryCompat.ts | 26 ++++++++++++---- core/auth/requestTimeout.ts | 4 +-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts index 14096711..c9ea94b9 100644 --- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts +++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts @@ -578,6 +578,36 @@ describe("probe cancellation (#2319)", () => { expect(cancels).toHaveBeenCalled(); }); + it("does not wait on a cancel that never settles", async () => { + // ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel + // promise, which is permitted never to settle — so awaiting the release on + // a path that is propagating a cancellation would hang the very thing it + // was meant to end. + const discovery = new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode("not found")); + }, + cancel() { + return new Promise(() => {}); + }, + }), + { status: 404 }, + ); + const inner = vi.fn((input) => + String(input) === RFC8414 + ? Promise.resolve(discovery) + : new Promise(() => {}), + ); + const wrapped = withRfc8414OidcCompat(inner); + const reason = new Error("gone"); + + // Would hang if the release were awaited; the assertion is that it settles. + await expect( + wrapped(RFC8414, { signal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason); + }); + it("still falls back to the original response on an ordinary probe failure", async () => { const inner = vi.fn((input) => { if (String(input) === RFC8414) { diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index d6ca43d3..ce0ace87 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -170,6 +170,22 @@ async function releaseBody(response: Response): Promise { await response.body?.cancel().catch(() => {}); } +/** + * The same release, started but not waited on. + * + * ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel promise, + * which is permitted never to settle — so awaiting it on a path that is + * *propagating a cancellation* can hang the very thing that was meant to end + * (Copilot). Every exceptional exit below uses this: the release is a courtesy + * to the connection pool, and the caller's abort or timeout must reach it + * regardless. The `void` is the documented case where the callee owns its + * failures — `releaseBody` swallows its own — and the caller genuinely cannot + * await. + */ +function releaseBodyDetached(response: Response): void { + void releaseBody(response); +} + /** * Whether a parsed body is RFC 8414 authorization-server metadata that is *not* * a valid OpenID provider document — the exact shape the upstream schema @@ -280,7 +296,7 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // exported wrapper, where no timeout wrapper is underneath to have // already drained it. if (callerSignal?.aborted) { - await releaseBody(response); + releaseBodyDetached(response); throw callerSignal.reason; } @@ -299,7 +315,7 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // stall the SDK would have hit itself, unbounded. Everything else — a // CORS rejection, DNS, a reset — still falls back as documented above. if (err instanceof OAuthRequestTimeoutError) { - await releaseBody(response); + releaseBodyDetached(response); throw err; } // Likewise a caller that gave up: substituting the preceding 404 for @@ -308,7 +324,7 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // here and at the two sites below, rather than whatever shape the // underlying layer happened to reject with. if (callerSignal?.aborted) { - await releaseBody(response); + releaseBodyDetached(response); throw callerSignal.reason; } return response; @@ -325,7 +341,7 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // response either, and `continue` would otherwise carry on probing for // a caller that has stopped waiting. if (callerSignal?.aborted) { - await releaseBody(response); + releaseBodyDetached(response); throw callerSignal.reason; } if (continuesDiscovery(probe.status)) continue; @@ -352,7 +368,7 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // that merely will not parse still falls through, which is the case // this catch exists for. if (callerSignal?.aborted) { - await releaseBody(response); + releaseBodyDetached(response); throw callerSignal.reason; } return response; diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index 7740b58c..7b771919 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -56,6 +56,8 @@ * shape, as `revokeToken`. */ +import { redactUrlQuery } from "../mcp/fetchTracking.js"; + /** * 30 seconds. Deliberately generous: discovery against a slow or cold-starting * authorization server is legitimate, and a bound that fires on a server that @@ -63,8 +65,6 @@ * halves the 60s the SDK handshake incidentally provides, and it is the only * bound at all on the legs that sit outside an SDK request. */ -import { redactUrlQuery } from "../mcp/fetchTracking.js"; - export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; /** From cf856c740cfd2c410a45c4be58ae5ee56934ea60 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 08:47:01 -0400 Subject: [PATCH 032/120] fix: do not await the discarded stream's cancel in /api/fetch (#2319 round 26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and last site of the same class. `ReadableStream.cancel()` adopts the underlying source's cancel promise, which is permitted never to settle, so awaiting it kept the handler pending forever — and on an unbounded request there is no timer to release it either. The cancel is started and the handler returns, so the JSON response and the `finally` complete regardless. The `void` carries the justification for which of AGENTS.md's permitted cases it is: the callee owns its failures through its own `catch`, and the caller cannot await. Test: a `/stream-hostile` upstream that sends event-stream headers and one frame and then never closes, asserting the route still answers 200 with no body. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall --- .../mcp/remote/server-extra-coverage.test.ts | 24 +++++++++++++++++++ core/mcp/remote/node/server.ts | 9 ++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 7d2fea1f..7314d514 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -353,6 +353,14 @@ describe("server.ts supplemental coverage", () => { }); const srv = createServer((req, res) => { upstreamHits.push(req.url ?? ""); + if (req.url === "/stream-hostile") { + // Headers, one frame, and a connection whose teardown the route must + // not wait on: `ReadableStream.cancel()` adopts the source's cancel + // promise, which may never settle. + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("data: hi\n\n"); + return; + } if (req.url === "/stream-open") { // Event-stream headers, one frame, and then nothing — the shape that // used to leave a socket open for good, because the route classifies @@ -568,6 +576,22 @@ describe("server.ts supplemental coverage", () => { expect(await settled).toBe("aborted by us"); }); + it("answers a discarded stream without waiting on its cancellation", async () => { + // ⚠️ The route starts the cancel and returns. Awaiting it would keep the + // handler pending on a source that never settles its cancel promise — + // and on an unbounded request there is no timer to release it either. + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stream-hostile` }), + }); + + expect(res.status).toBe(200); + const payload = (await res.json()) as { status: number; body?: string }; + expect(payload.status).toBe(200); + expect(payload.body).toBeUndefined(); + }); + it("routes the outbound request through HTTP_PROXY (#2067)", async () => { // /api/fetch is the browser's ONLY way out to the network — the web // client's `environment.fetch` is `createRemoteFetch()`, which forwards diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index ccdc7a8b..15179eb1 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1323,7 +1323,14 @@ export function createRemoteApp( // its own in this configuration — so this is belt and braces rather // than a demonstrated leak, and it is kept because relying on that is // an implementation detail of the fetch beneath us, not a contract. - await res.body?.cancel().catch(() => {}); + // + // ⚠️ Started, not awaited. `ReadableStream.cancel()` adopts the + // underlying source's cancel promise, which is permitted never to + // settle — awaiting it would keep this handler pending forever, and on + // an unbounded request there is no timer to release it either + // (Copilot). The `void` is the documented case where the callee owns + // its failures, via the `catch` below, and the caller cannot await. + void res.body?.cancel().catch(() => {}); } return c.json({ From 82b13a1a127d9f070d7056e3ed19de0d896a4c13 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 09:30:17 -0400 Subject: [PATCH 033/120] fix: bold the labels, not the values, and give Connection Info's URLs full width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection Info rendered every label/value row as a plain label against a bold value. That is backwards for a reference panel: the label is the fixed scaffolding a reader scans down, the value is what differs per connection, and bolding every value means nothing stands out. Reversed across the whole modal — Server Implementation, Discovery and OAuth Details, plus the token captions — rather than in one section, since two halves disagreeing is worse than either convention. Client ID and Auth URL were a Code block in the right half of a two-column grid, so a long value got roughly half the modal width. A CIMD client id IS a URL, so it is long by construction, and it broke mid-token — `…/client-metadata.` / `json` — which reads as a rendering fault rather than as one value. Both now put their label on its own line with the value full width beneath, the way OAuthTokenField already lays out a token, and neither carries an inset surface any more: the grey framed a value that is simply the answer to "Client ID", not a quoted blob needing separation from prose. The transparent treatment is a new `wrapping-plain` theme variant rather than a change to `wrapping`. That one is shared with ElicitationUrlPanel, ListLoadError, MalformedItemsWarning and the CSV fallback, where the inset is doing real work; a test asserts `wrapping` keeps its surface so those consumers cannot be changed by accident. Closes #2328 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ConnectionInfoContent.test.tsx | 58 ++++++++++++++++++ .../ConnectionInfoContent.tsx | 55 ++++++++++------- .../ConnectionInfoContent/OAuthTokenField.tsx | 5 +- clients/web/src/theme/Code.test.tsx | 60 +++++++++++++++++++ clients/web/src/theme/Code.ts | 12 +++- 5 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 clients/web/src/theme/Code.test.tsx diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index d181c36a..53b225e1 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -567,6 +567,64 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("token-123")).toBeInTheDocument(); }); + it("weights labels bold and values normal, in both halves of the modal", () => { + renderWithMantine( + , + ); + + // The convention is the whole point of #2328: the label is the fixed + // scaffolding a reader scans down, the value is what differs. Asserted in + // Server Implementation *and* OAuth Details, because the defect being + // guarded against is the two halves disagreeing — checking one alone would + // pass on a modal that is internally inconsistent. + for (const label of ["Name", "Protocol", "Client ID", "Scopes"]) { + expect(screen.getAllByText(label)[0]).toHaveStyle({ fontWeight: "600" }); + } + for (const value of ["Everything Server", "read"]) { + expect(screen.queryByText(value)?.style.fontWeight ?? "").not.toBe("600"); + } + }); + + it("gives Client ID and Auth URL the full width, with no inset surface", () => { + renderWithMantine( + , + ); + + // A CIMD client id IS a URL, so it is long by construction. In the + // two-column grid it got half the modal and broke mid-token + // (`…/client-metadata.` / `json`), which reads as a rendering fault rather + // than as one value. + for (const value of [ + "http://127.0.0.1:8093/client-metadata.json?profile=long-enough-to-wrap", + "https://auth.example.com/authorize", + ]) { + expect(screen.getByText(value)).toHaveStyle({ + backgroundColor: "transparent", + }); + } + }); + it("renders EMA idp session when provided", () => { renderWithMantine( void; } -const ValueText = Text.withProps({ +// Label/value pairs in this modal read label-bold, value-normal: the label is +// the fixed scaffolding a reader scans down, and the value is the thing that +// differs per connection. The reverse (which this was until #2328) bolded every +// answer, so nothing stood out and the two columns fought each other. +const FieldLabel = Text.withProps({ size: "sm", fw: 600, }); +const ValueText = Text.withProps({ + size: "sm", +}); + // Shown for Name/Version when the server didn't report `serverInfo` — an em dash // plus an explicit note so the client-side catalog fallback is never mistaken // for a value the server sent. Exported so tests/stories assert against it @@ -104,7 +112,14 @@ const SectionHeading = Title.withProps({ // `Code` block — that keeps the whole value visible and removes a scroll region // that would otherwise need its own keyboard access (axe // `scrollable-region-focusable`). -const ValueCode = Code.withProps({ variant: "wrapping" }); +const ValueCode = Code.withProps({ variant: "wrapping-plain" }); + +// A long OAuth value (client id, auth URL) gets its label on its own line and +// the value across the full modal width beneath it, the way `OAuthTokenField` +// already lays out a token. In the two-column grid these values had roughly +// half the width and wrapped mid-token — `…/client-metadata.` / `json` — which +// reads as a rendering fault rather than as one URL (#2328). +const FullWidthField = Stack.withProps({ gap: 4 }); // One declared sub-option of an extension. Mirrors `CapabilityItem`'s ✓/✗ row // rather than reusing it: that element's `capability` prop is the closed union @@ -306,22 +321,22 @@ export function ConnectionInfoContent({ Server Implementation - Name + Name {displayName} - Version + Version {displayVersion} - Protocol + Protocol {protocolVersion || "—"} - Transport + Transport {transport} - Era + Era - Session + Session {formatSession(protocolEra, transport)} @@ -330,7 +345,7 @@ export function ConnectionInfoContent({ Discovery - Supported versions + Supported versions {discoverResult.supportedVersions.length > 0 ? discoverResult.supportedVersions.join(", ") @@ -433,10 +448,10 @@ export function ConnectionInfoContent({ OAuth Details - Protocol + Protocol {formatProtocol(oauth.protocol)} - Status + Status {oauth.clientId && ( - - Client ID + + Client ID {oauth.clientId} - + )} {oauth.clientRegistrationKind && ( - Client registration + Client registration {formatClientRegistrationKind(oauth.clientRegistrationKind)} @@ -460,19 +475,19 @@ export function ConnectionInfoContent({ )} {oauth.protocol === "ema" && oauth.idpSession && ( - IdP session + IdP session {formatIdpSession(oauth.idpSession)} )} {oauth.authUrl && ( - - Auth URL + + Auth URL {oauth.authUrl} - + )} {oauth.scopes && oauth.scopes.length > 0 && ( - Scopes + Scopes {formatScopes(oauth.scopes)} )} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx index abd5c4a9..d2031938 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx @@ -26,7 +26,10 @@ const CaptionRow = Flex.withProps({ wrap: "nowrap", }); -const Caption = Text.withProps({ size: "sm" }); +// Bold, matching every other field label in Connection Info (#2328) — the +// token rows sit in the same OAuth Details list as Client ID and Scopes, so a +// lighter caption here would break the column a reader scans down. +const Caption = Text.withProps({ size: "sm", fw: 600 }); const Toolbar = Flex.withProps({ gap: 4, diff --git a/clients/web/src/theme/Code.test.tsx b/clients/web/src/theme/Code.test.tsx new file mode 100644 index 00000000..a78fea4b --- /dev/null +++ b/clients/web/src/theme/Code.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { Code } from "@mantine/core"; +import { renderWithMantine } from "../test/renderWithMantine"; + +// `ThemeCode.extend` is applied transitively wherever a Code renders, but no +// single component exercises every branch of its styles callback. Same reason +// as `Paper.test.tsx` (#1787 brought theme/** under the coverage gate). +// +// The load-bearing assertion here is the `wrapping` / `wrapping-plain` split +// (#2328): `wrapping-plain` exists so Connection Info can drop the inset +// surface behind a label/value pair *without* changing `wrapping`, which +// ElicitationUrlPanel, ListLoadError, MalformedItemsWarning and the CSV +// fallback all rely on to separate a raw value from the prose around it. + +describe("ThemeCode variants", () => { + it("renders the default variant", () => { + const { getByText } = renderWithMantine(default); + expect(getByText("default")).toBeTruthy(); + }); + + it("wraps long values in both wrapping variants", () => { + for (const variant of ["wrapping", "wrapping-plain"] as const) { + const { getByText } = renderWithMantine( + {variant}, + ); + const style = getByText(variant).style; + expect(style.wordBreak).toBe("break-all"); + expect(style.whiteSpace).toBe("pre-wrap"); + } + }); + + it("strips the inset surface for wrapping-plain only", () => { + const { getByText: getPlain } = renderWithMantine( + plain, + ); + expect(getPlain("plain").style.backgroundColor).toBe("transparent"); + expect(getPlain("plain").style.padding).toBe("0px"); + + // The guard that makes this change safe: `wrapping` keeps its surface, so + // every other consumer of it is untouched. + const { getByText: getInset } = renderWithMantine( + inset, + ); + expect(getInset("inset").style.backgroundColor).not.toBe("transparent"); + expect(getInset("inset").style.padding).not.toBe("0px"); + }); + + it("clips instead of wrapping for the nowrap variant", () => { + const { getByText } = renderWithMantine(n); + const style = getByText("n").style; + expect(style.whiteSpace).toBe("nowrap"); + expect(style.overflow).toBe("hidden"); + expect(style.textOverflow).toBe("ellipsis"); + }); + + it("drops the margin on a block Code", () => { + const { getByText } = renderWithMantine(b); + expect(getByText("b").style.margin).toBe("0px"); + }); +}); diff --git a/clients/web/src/theme/Code.ts b/clients/web/src/theme/Code.ts index b54a8eee..86efd014 100644 --- a/clients/web/src/theme/Code.ts +++ b/clients/web/src/theme/Code.ts @@ -10,10 +10,20 @@ export const ThemeCode = Code.extend({ backgroundColor: "var(--inspector-code-surface, var(--inspector-surface-code))", }; - if (props.variant === "wrapping") { + if (props.variant === "wrapping" || props.variant === "wrapping-plain") { root.wordBreak = "break-all"; root.whiteSpace = "pre-wrap"; } + if (props.variant === "wrapping-plain") { + // Wraps like `wrapping`, but with no inset surface behind it. Used for + // label/value rows that read as *values* rather than as quoted blobs — + // Connection Info's Client ID and Auth URL. A separate variant rather + // than a tweak to `wrapping`, because that one is shared with panels + // (elicitation URLs, load errors, CSV fallback) where the grey inset is + // doing real work separating a raw value from the prose around it. + root.backgroundColor = "transparent"; + root.padding = "0"; + } if (props.block) { root.margin = "0"; } From 664c9f88006a0b7ff873ee42da85b7af6639febf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 09:56:33 -0400 Subject: [PATCH 034/120] fix: group Connection Info's full-width OAuth fields above the token rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client ID sat third in OAuth Details, between Status and Client registration, so its label-over-value block interrupted the run of inline two-column rows and broke the scan down the label column for everything after it. Access Token and ID Token already used that layout, at the bottom. Moved Client ID and Auth URL down to sit with them: the inline rows now run Protocol, Status, Client registration, IdP session, Scopes, and the full-width fields follow as one group — Auth URL, Client ID, Access Token, ID Token — so Client ID is directly above the only other field a reader sees laid out the same way. A test asserts the order off the DOM rather than each field's presence, since order is the property; putting Client ID back in its old slot fails it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ConnectionInfoContent.test.tsx | 51 +++++++++++++++++++ .../ConnectionInfoContent.tsx | 28 ++++++---- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 53b225e1..1a525839 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -625,6 +625,57 @@ describe("ConnectionInfoContent", () => { } }); + it("groups the full-width fields at the end, Client ID directly above Access Token", () => { + renderWithMantine( + , + ); + + // Order is the assertion, so read the labels off the DOM rather than + // checking each is merely present: the inline two-column rows come first, + // then every label-over-value field together. Interleaving them is what + // this guards against — it broke the scan down the label column, and the + // tokens already used the full-width layout at the bottom. + const order = [ + "Client registration", + "Scopes", + "Auth URL", + "Client ID", + "Access Token", + ]; + const positions = order.map((label) => { + const node = screen.getAllByText(label)[0]; + expect(node).toBeInTheDocument(); + return ( + node.compareDocumentPosition(screen.getAllByText("Protocol")[0]) & + Node.DOCUMENT_POSITION_PRECEDING + ); + }); + expect(positions.every(Boolean)).toBe(true); + + const labelNode = (label: string) => screen.getAllByText(label)[0]; + for (let i = 0; i < order.length - 1; i++) { + const earlier = labelNode(order[i]); + const later = labelNode(order[i + 1]); + expect( + earlier.compareDocumentPosition(later) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + }); + it("renders EMA idp session when provided", () => { renderWithMantine( - {oauth.clientId && ( - - Client ID - {oauth.clientId} - - )} {oauth.clientRegistrationKind && ( Client registration @@ -479,17 +473,29 @@ export function ConnectionInfoContent({ {formatIdpSession(oauth.idpSession)} )} + {oauth.scopes && oauth.scopes.length > 0 && ( + + Scopes + {formatScopes(oauth.scopes)} + + )} + {/* The full-width fields are kept together at the end of the + section, directly above the token rows, which use the same + label-over-value layout. Interleaved with the inline two-column + rows they broke the scan down the label column for every row + after them, and Client ID — the one most likely to be long — + was the worst offender (#2328). */} {oauth.authUrl && ( Auth URL {oauth.authUrl} )} - {oauth.scopes && oauth.scopes.length > 0 && ( - - Scopes - {formatScopes(oauth.scopes)} - + {oauth.clientId && ( + + Client ID + {oauth.clientId} + )} {oauth.accessToken && ( Date: Fri, 11 Sep 2026 10:07:22 -0400 Subject: [PATCH 035/120] fix: carry the value convention to the badges, and pin the layout it claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 1 on #2329. Four findings, all correct. The whole-modal claim was false for the three rows whose value is a Badge: `ThemeBadge` defaults to `fw: 600`, so Transport, Era and Status still rendered bold-label/bold-value. A `ValueBadge` constant carries the normal weight for Transport and Status, and `EraBadge` takes an optional `fw` so Connection Info can ask for it without changing the standalone chip in ProtocolListPanel. The chip still reads as a chip — its emphasis is the outline and colour, not the font weight. Three test weaknesses, each of which let a regression through: - The weight assertion used `queryByText(...)?.style.fontWeight ?? ""`, which passes vacuously when the row is absent entirely. Now `getByText`, and deleting a value row fails three tests instead of none. - The full-width test asserted only the transparent background, so swapping `FullWidthField` back for the two-column `SimpleGrid` would still have passed. It now asserts the structure: label and value are siblings, and the value is in no SimpleGrid. - `OAuthTokenField`'s bold caption was asserted nowhere — the weight test renders no token. A case now covers Access Token and ID Token through it. Note for anyone asserting a Badge's weight later: `getByText` lands on the inner label span and `fw` is applied to the root, so the assertion has to walk up or it reads an empty string and passes against anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../components/elements/EraBadge/EraBadge.tsx | 12 +++- .../ConnectionInfoContent.test.tsx | 55 ++++++++++++++++++- .../ConnectionInfoContent.tsx | 18 +++--- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/clients/web/src/components/elements/EraBadge/EraBadge.tsx b/clients/web/src/components/elements/EraBadge/EraBadge.tsx index 37c4ebe4..d3caa1d0 100644 --- a/clients/web/src/components/elements/EraBadge/EraBadge.tsx +++ b/clients/web/src/components/elements/EraBadge/EraBadge.tsx @@ -5,14 +5,22 @@ import { formatEra, isModernEra } from "./eraUtils"; export interface EraBadgeProps { /** The negotiated protocol era; `undefined` renders as Legacy. */ era: ProtocolEra | undefined; + /** + * Font weight override. The app-wide `ThemeBadge` defaults to `fw: 600`, + * which is right where this badge is a standalone chip (ProtocolListPanel's + * header). Connection Info renders it as the *value* half of a label/value + * row, where the convention is bold label / normal value, so it passes 400. + * Left undefined the theme default applies (#2328). + */ + fw?: number; } // Labels a connection's negotiated protocol era (SEP §7.8). Feed it from // connection state only — see the note in `eraUtils` on why the era must never // be inferred from individual message frames. -export function EraBadge({ era }: EraBadgeProps) { +export function EraBadge({ era, fw }: EraBadgeProps) { return ( - + {formatEra(era)} ); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 1a525839..ba4fb132 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -590,8 +590,22 @@ describe("ConnectionInfoContent", () => { for (const label of ["Name", "Protocol", "Client ID", "Scopes"]) { expect(screen.getAllByText(label)[0]).toHaveStyle({ fontWeight: "600" }); } + // `getByText`, not `queryByText` + `?? ""`: an absent value would make the + // optional form pass vacuously (undefined → "" → not "600"), so the test + // would go green on a row that had stopped rendering at all. for (const value of ["Everything Server", "read"]) { - expect(screen.queryByText(value)?.style.fontWeight ?? "").not.toBe("600"); + expect(screen.getByText(value).style.fontWeight).not.toBe("600"); + } + + // Badge values count too. `ThemeBadge` defaults to `fw: 600`, so Status, + // Transport and Era read bold-label/bold-value unless overridden — the + // whole-modal claim is false without this. + for (const badge of ["streamable-http", "Legacy", "Authorized"]) { + // `getByText` lands on the Badge's inner label span; `fw` is applied to + // the root, so walk up to it or the assertion reads an empty string and + // passes against anything. + const root = screen.getByText(badge).closest('[class*="Badge-root"]'); + expect((root as HTMLElement | null)?.style.fontWeight).toBe("400"); } }); @@ -623,6 +637,45 @@ describe("ConnectionInfoContent", () => { backgroundColor: "transparent", }); } + + // The background alone does not pin the *layout*: swapping FullWidthField + // back for the two-column SimpleGrid would keep it transparent and still + // pass. Assert the structure that makes the value full width — label and + // value are siblings in a column, and neither sits in a SimpleGrid. + for (const [label, value] of [ + [ + "Client ID", + "http://127.0.0.1:8093/client-metadata.json?profile=long-enough-to-wrap", + ], + ["Auth URL", "https://auth.example.com/authorize"], + ]) { + const labelNode = screen.getByText(label); + const valueNode = screen.getByText(value); + expect(valueNode.parentElement).toBe(labelNode.parentElement); + expect(valueNode.closest('[class*="SimpleGrid"]')).toBeNull(); + } + }); + + it("bolds the token captions, which are field labels in the same list", () => { + renderWithMantine( + , + ); + + // OAuthTokenField renders its own caption, so the weight convention has to + // be asserted through it — the other weight test renders no token at all. + for (const caption of ["Access Token", "ID Token"]) { + expect(screen.getByText(caption).style.fontWeight).toBe("600"); + } }); it("groups the full-width fields at the end, Client ID directly above Access Token", () => { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 447f1f79..1c04c951 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -92,6 +92,13 @@ const ValueText = Text.withProps({ size: "sm", }); +// A badge standing in as the *value* half of a label/value row. The app-wide +// `ThemeBadge` defaults to `fw: 600`, which is right for a standalone chip but +// makes these rows read bold-label/bold-value — the one convention this modal +// is not supposed to have. The chip still reads as a chip: its emphasis comes +// from the outline and colour, not the font weight (#2328). +const ValueBadge = Badge.withProps({ variant: "outline", fw: 400 }); + // Shown for Name/Version when the server didn't report `serverInfo` — an em dash // plus an explicit note so the client-side catalog fallback is never mistaken // for a value the server sent. Exported so tests/stories assert against it @@ -331,10 +338,10 @@ export function ConnectionInfoContent({ {protocolVersion || "—"} Transport - {transport} + {transport} Era - + Session {formatSession(protocolEra, transport)} @@ -452,12 +459,9 @@ export function ConnectionInfoContent({ {formatProtocol(oauth.protocol)} Status - + {oauth.authorized ? "Authorized" : "Not authorized"} - + {oauth.clientRegistrationKind && ( From 58e53d5a9230e5c5e0a1d1e1e3d59a0b08d7f456 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 12 Sep 2026 08:32:34 -0400 Subject: [PATCH 036/120] fix: state every test-gate timeout instead of inheriting a library default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library waits in the web unit project ran on asyncUtilTimeout: 1000. None of those numbers was chosen here, and all of them were sized for an idle machine rather than for three or four concurrent worktree sessions each running a full local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that class, diagnosed one site at a time. This is the complement of #1596, not a retreat from it: every fix that issue produced stands, and no race is hidden here. What changes is that a correct, deterministic test is no longer cut off by a budget nobody picked. - vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six projects spread one of them, clients/launcher importing the shared module for the first time to do so. - Both web projects configure Testing Library's asyncUtilTimeout. The storybook one needs its own setup file, deliberately outside .storybook/ and free of setProjectAnnotations so the automatic preview-annotation provisioning (#1898) is untouched. - 28 per-suite restatements of the integration project's own budget are gone, so raising the shared value now moves every site under it. The handful that genuinely differ — cross-process lock contention, a full OAuth round trip — keep their budget as a named constant that says why. - AppRenderer's waitFor no longer asks for exactly its enclosing budget, which it could never win (#2292's shape); cli's e2e child timer drops below the project budget so the bound carrying the diagnostic is the one that fires. - The transition auto-settle's slack term is named RAF_SLACK_MS and raised; the term derived from HEADER_ANIM_MS is untouched. The two answer to different things and must not be scaled as one number. - The 19 Playwright budgets scattered across three web smokes become four named constants in scripts/lib/browser-timeouts.mjs. scripts/verify-test-timeouts.mjs resolves each project through Vitest itself and asserts the budgets, the asyncUtilTimeout sites, and that retry stays unset. It was observed red against the pre-change config: unit/tui/launcher at testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout sites unmet. Closes #2323 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall --- .claude/skills/pre-push-gate/SKILL.md | 16 + AGENTS.md | 44 +++ clients/cli/__tests__/e2e.test.ts | 13 +- .../cli/__tests__/oauth-interactive.test.ts | 282 +++++++++-------- clients/cli/vitest.config.ts | 6 +- clients/launcher/tsconfig.test.json | 10 +- clients/launcher/vitest.config.ts | 7 + clients/tui/__tests__/ResourcesTab.test.tsx | 16 +- clients/tui/__tests__/logger.test.ts | 13 +- clients/tui/vitest.config.ts | 6 +- clients/web/src/App.test.tsx | 99 +++--- .../elements/AppRenderer/AppRenderer.test.tsx | 20 +- .../groups/ViewHeader/ViewHeader.test.tsx | 14 +- .../integration/auth/node/file-lock.test.ts | 267 +++++++++------- .../auth/node/secret-store-selection.test.ts | 4 + .../integration/auth/revocation-e2e.test.ts | 4 +- .../auth/rfc8414AtOidcPath.test.ts | 4 +- .../inspectorClient-modern-era-oauth.test.ts | 6 +- .../mcp/inspectorClient-modern-era.test.ts | 4 +- ...lient-oauth-direct-mid-session-e2e.test.ts | 6 +- ...lient-oauth-remote-mid-session-e2e.test.ts | 12 +- .../oauth-resource-metadata-challenge.test.ts | 8 +- .../remote/cancel-per-request-stream.test.ts | 2 +- clients/web/src/test/renderWithMantine.tsx | 54 +++- clients/web/src/test/setup.ts | 21 +- clients/web/src/test/storybookSetup.ts | 30 ++ clients/web/vite.config.ts | 43 ++- docs/quality-gate.md | 3 +- package.json | 3 +- scripts/lib/browser-timeouts.mjs | 49 +++ scripts/lib/browser-timeouts.test.mjs | 27 ++ scripts/smoke-web-browser.mjs | 7 +- scripts/smoke-web-elicitation.mjs | 28 +- scripts/smoke-web-tabs.mjs | 17 +- scripts/verify-test-timeouts.mjs | 290 ++++++++++++++++++ scripts/verify-test-timeouts.test.mjs | 150 +++++++++ vitest.shared.mts | 67 +++- 37 files changed, 1267 insertions(+), 385 deletions(-) create mode 100644 clients/web/src/test/storybookSetup.ts create mode 100644 scripts/lib/browser-timeouts.mjs create mode 100644 scripts/lib/browser-timeouts.test.mjs create mode 100644 scripts/verify-test-timeouts.mjs create mode 100644 scripts/verify-test-timeouts.test.mjs diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index 1b3379fe..66db5b7c 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -87,6 +87,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, a web setup +file stopped configuring Testing Library's `asyncUtilTimeout`, or someone added +a `retry`. 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 diff --git a/AGENTS.md b/AGENTS.md index e80ed76f..5ae0690d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,50 @@ 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. +- **Never nest a budget inside an equal-or-larger one.** `waitFor({ timeout: N })` + inside a test whose own budget is `N` can never win, and it reports as a + timeout naming neither the wait nor its subject (#2292). Two bounds on the + same work must differ, and the one carrying the useful diagnostic must be the + tighter (`clients/cli/__tests__/e2e.test.ts`). +- **`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. The guard enforces this. +- **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. diff --git a/clients/cli/__tests__/e2e.test.ts b/clients/cli/__tests__/e2e.test.ts index 4c3c990e..b6273ae0 100644 --- a/clients/cli/__tests__/e2e.test.ts +++ b/clients/cli/__tests__/e2e.test.ts @@ -7,6 +7,17 @@ import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-se const here = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(here, "../build/index.js"); +/** + * In-test bound on the spawned child, deliberately BELOW the cli project's own + * `testTimeout` (15000, from `vitest.shared.mts`). The two used to be equal, so + * which one fired was arbitrary — and they do different things: this one kills + * the child's process group and rejects with a message naming the CLI, while + * the project budget just fails the test with a generic timeout and leaves a + * detached `node` behind. The useful diagnostic is here, so this is the bound + * that must win (#2323). + */ +const CHILD_TIMEOUT_MS = 12_000; + interface SpawnResult { exitCode: number | null; stdout: string; @@ -36,7 +47,7 @@ function spawnCli(args: string[]): Promise { child.kill("SIGTERM"); } reject(new Error("E2E CLI timed out")); - }, 15000); + }, CHILD_TIMEOUT_MS); child.stdout.on("data", (d) => (stdout += d.toString())); child.stderr.on("data", (d) => (stderr += d.toString())); child.on("error", (err) => { diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts index db1cad72..f6c6d77b 100644 --- a/clients/cli/__tests__/oauth-interactive.test.ts +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -1,3 +1,12 @@ +/** + * Interactive OAuth against a real HTTP test server. + * + * Every test here raises past the cli project's shared 15s budget, and is meant + * to: each boots a server, waits for its well-known documents, runs a full + * authorization round trip, then disconnects. That is elapsed work, not slack + * for a loaded machine, so it stays stated per test rather than folding into + * the shared value (#2323). + */ import { describe, it, expect, afterEach, vi } from "vitest"; import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -90,6 +99,9 @@ const callbackUrlConfig = { }; const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback"; +/** A full interactive OAuth round trip against a freshly booted server. */ +const OAUTH_ROUND_TRIP_MS = 30_000; + describe("CLI interactive OAuth (integration)", () => { let mcpServer: TestServerHttp | null = null; @@ -104,148 +116,156 @@ describe("CLI interactive OAuth (integration)", () => { } catch { // ignore } - }, 30_000); + }, OAUTH_ROUND_TRIP_MS); - it("connects to an OAuth-protected server via the loopback callback server", async () => { - const serverConfig = { - ...getDefaultServerConfig(), - serverType: "streamable-http" as const, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, - }), - }; + it( + "connects to an OAuth-protected server via the loopback callback server", + async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; - mcpServer = new TestServerHttp(serverConfig); - const port = await mcpServer.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); - const redirectUrlProvider = new MutableRedirectUrlProvider(); - redirectUrlProvider.redirectUrl = presetRedirectUrl; - const navigation = createAutoCompleteNavigation(redirectUrlProvider); - const client = new InspectorClient( - { - type: "streamable-http", - url: `${serverUrl}/mcp`, - } as MCPServerConfig, - { - environment: { - transport: createTransportNode, - oauth: { - storage: new NodeOAuthStorage(oauthTestStatePath), - navigation, - redirectUrlProvider, + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, }, + directAuthRecovery: true, + oauth: {}, }, - directAuthRecovery: true, - oauth: {}, - }, - ); + ); - await connectInspectorWithOAuth( - client, - { type: "streamable-http", url: `${serverUrl}/mcp` }, - redirectUrlProvider, - callbackUrlConfig, - undefined, - { isTTY: true }, - ); - - const tools = await client.listTools(); - expect(tools.tools.length).toBeGreaterThan(0); - await client.disconnect(); - }, 30_000); - - it("retries an RPC after step-up authorization when the user confirms", async () => { - const baseConfig = getDefaultServerConfig(); - - const serverConfig = { - ...baseConfig, - serverType: "streamable-http" as const, - tools: baseConfig.tools!.map((tool) => - tool.name === "get_temp" - ? { ...tool, requiredScopes: ["weather:read"] } - : tool, - ), - ...createOAuthTestServerConfig({ - requireAuth: true, - supportRefreshTokens: true, - supportDCR: true, - }), - }; - - mcpServer = new TestServerHttp(serverConfig); - const port = await mcpServer.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - - const redirectUrlProvider = new MutableRedirectUrlProvider(); - redirectUrlProvider.redirectUrl = presetRedirectUrl; - const navigation = createAutoCompleteNavigation(redirectUrlProvider); - const client = new InspectorClient( - { - type: "streamable-http", - url: `${serverUrl}/mcp`, - } as MCPServerConfig, - { - environment: { - transport: createTransportNode, - oauth: { - storage: new NodeOAuthStorage(oauthTestStatePath), - navigation, - redirectUrlProvider, - }, - }, - directAuthRecovery: true, - oauth: { - scope: "mcp tools:read", - }, - }, - ); - - await connectInspectorWithOAuth( - client, - { type: "streamable-http", url: `${serverUrl}/mcp` }, - redirectUrlProvider, - callbackUrlConfig, - undefined, - { isTTY: true }, - ); - - const tools = await client.listTools(); - const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); - expect(getTempTool).toBeDefined(); - - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); - - try { - const result = await withCliAuthRecoveryRetry( + await connectInspectorWithOAuth( client, { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, - makeFakeServerSettings(), - () => - client.callTool(getTempTool!, { - city: "NYC", - units: "C", - }), - { confirmStepUp: async () => true, isTTY: true }, + undefined, + { isTTY: true }, ); - const stepUpPrompted = stderrSpy.mock.calls.some( - ([chunk]) => - typeof chunk === "string" && - chunk.includes("Proceed with step-up authorization?"), - ); - expect(stepUpPrompted).toBe(true); - expect(result.success).toBe(true); - } finally { - stderrSpy.mockRestore(); + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); await client.disconnect(); - } - }, 30_000); + }, + OAUTH_ROUND_TRIP_MS, + ); + + it( + "retries an RPC after step-up authorization when the user confirms", + async () => { + const baseConfig = getDefaultServerConfig(); + + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + scope: "mcp tools:read", + }, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + undefined, + { isTTY: true }, + ); + + const tools = await client.listTools(); + const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + const result = await withCliAuthRecoveryRetry( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + makeFakeServerSettings(), + () => + client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }), + { confirmStepUp: async () => true, isTTY: true }, + ); + + const stepUpPrompted = stderrSpy.mock.calls.some( + ([chunk]) => + typeof chunk === "string" && + chunk.includes("Proceed with step-up authorization?"), + ); + expect(stepUpPrompted).toBe(true); + expect(result.success).toBe(true); + } finally { + stderrSpy.mockRestore(); + await client.disconnect(); + } + }, + OAUTH_ROUND_TRIP_MS, + ); }); diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts index 6ebb583b..92a0852c 100644 --- a/clients/cli/vitest.config.ts +++ b/clients/cli/vitest.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { TIMEOUTS, vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -13,7 +13,9 @@ export default defineConfig({ environment: "node", include: ["__tests__/**/*.test.ts"], setupFiles: ["__tests__/helpers/mock-open-url.ts"], - testTimeout: 15000, + // Shared budgets (#2323). `testTimeout` was already 15000 here by hand; + // the hook and teardown budgets were Vitest's defaults until now. + ...TIMEOUTS, // The in-process runner (__tests__/helpers/cli-runner.ts) patches // process.std{out,err}.write to capture CLI output. Test files run in // separate forked processes (and tests within a file run sequentially), so diff --git a/clients/launcher/tsconfig.test.json b/clients/launcher/tsconfig.test.json index 19cbf076..2c170602 100644 --- a/clients/launcher/tsconfig.test.json +++ b/clients/launcher/tsconfig.test.json @@ -6,7 +6,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, - "rootDir": ".", + // Wide enough to contain `vitest.shared.mts`, which `vitest.config.ts` + // imports for the shared test budgets (#2323). `rootDir` only shapes error + // reporting under `noEmit`, and the emitting build config next door keeps + // its own `./src` — this project compiles nothing. `cli` and `tui` reach + // the same file with no `rootDir` at all, which infers the same thing. + "rootDir": "../..", + // Same import: `.mts` in a specifier needs this, and it is safe for the + // same reason — nothing here is emitted. `cli` and `tui` both set it. + "allowImportingTsExtensions": true, "types": ["node"] }, // `vitest.config.ts` lives here (not the src project) because the build diff --git a/clients/launcher/vitest.config.ts b/clients/launcher/vitest.config.ts index eef71846..f0e0b07f 100644 --- a/clients/launcher/vitest.config.ts +++ b/clients/launcher/vitest.config.ts @@ -1,10 +1,17 @@ import { defineConfig } from "vitest/config"; +import { TIMEOUTS } from "../../vitest.shared.mts"; export default defineConfig({ test: { globals: false, environment: "node", include: ["__tests__/**/*.test.ts"], + // Shared budgets (#2323). This project holds a single test file, which is + // exactly why it is pinned rather than left on the defaults: it is cheap to + // include and expensive to remember when a second file arrives. This import + // is the only thing this config takes from the shared module — the resolve + // aliases are for clients that load React or `core/`. + ...TIMEOUTS, coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/clients/tui/__tests__/ResourcesTab.test.tsx b/clients/tui/__tests__/ResourcesTab.test.tsx index ad1fa78d..621abd7c 100644 --- a/clients/tui/__tests__/ResourcesTab.test.tsx +++ b/clients/tui/__tests__/ResourcesTab.test.tsx @@ -27,11 +27,23 @@ const tick = async () => { await new Promise((resolve) => setTimeout(resolve, 4)); }; -/** Poll the frame until it contains `substr` — stable under coverage load. */ +/** + * Poll the frame until it contains `substr` — stable under coverage load. + * + * The default budget (`tries` x the ~32ms `tick` below) matches `App.test.tsx`'s + * `POLL_TRIES`, which was raised twice for exactly this shape (#1742, #1942). + * It used to be 25 here — one third of the same renderer's budget under the + * same v8 instrumentation, for no stated reason, which made this the next one + * to go red. A high ceiling costs a passing assertion nothing: the loop returns + * the instant the predicate holds and only the slow path ever spends it + * (#2323). + */ +const POLL_TRIES = 100; + async function waitForFrame( getFrame: () => string | undefined, substr: string, - tries = 25, + tries = POLL_TRIES, ) { for (let i = 0; i < tries; i++) { if ((getFrame() ?? "").includes(substr)) return; diff --git a/clients/tui/__tests__/logger.test.ts b/clients/tui/__tests__/logger.test.ts index 426f2a16..d3226276 100644 --- a/clients/tui/__tests__/logger.test.ts +++ b/clients/tui/__tests__/logger.test.ts @@ -9,8 +9,19 @@ import { join } from "node:path"; * file (and any parent dir) appears a tick later. Awaiting this before cleanup * also guarantees the background mkdir/open has finished, so `afterEach`'s * rmSync can't race it into an ENOENT. + * + * The budget is a ceiling, not a wait: the loop exits on the first `existsSync` + * that succeeds, so a passing run pays only the 20ms poll interval and the + * headroom is spent only when a real filesystem write is genuinely slow — which + * is what an oversubscribed machine produces (#2323). 2000ms was Vitest-era + * guesswork against an idle one. */ -async function waitForFile(filePath: string, timeoutMs = 2000): Promise { +const FILE_APPEAR_TIMEOUT_MS = 10_000; + +async function waitForFile( + filePath: string, + timeoutMs = FILE_APPEAR_TIMEOUT_MS, +): Promise { const start = Date.now(); while (!existsSync(filePath)) { if (Date.now() - start > timeoutMs) { diff --git a/clients/tui/vitest.config.ts b/clients/tui/vitest.config.ts index d2528b94..84433522 100644 --- a/clients/tui/vitest.config.ts +++ b/clients/tui/vitest.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { TIMEOUTS, vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -13,6 +13,10 @@ export default defineConfig({ environment: "node", include: ["__tests__/**/*.test.{ts,tsx}"], setupFiles: ["./__tests__/setup.ts"], + // Shared budgets (#2323). Ink renders through React, so every assertion + // here waits on a React commit under v8 instrumentation — the shape behind + // the poll-budget raises in #1742 and #1942. + ...TIMEOUTS, coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index ab929146..40f54d91 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -4295,59 +4295,58 @@ describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", ); } - // Three full modal interaction sequences against the whole App tree, so this - // one runs long enough to trip the 5s default when the suite is under load. - it( - "suppresses a second clear for another entry with the same URL, and allows one after it settles", - { timeout: 20000 }, - async () => { - // `delay: null` drops userEvent's inter-event waits, which dominate here. - const user = userEvent.setup({ delay: null }); - renderWithMantine(); + // Three full modal interaction sequences against the whole App tree. This + // used to carry `{ timeout: 20000 }` because the project ran on Vitest's 5s + // default and this test tripped it under load; the project now states 15000 + // itself (#2323), so the raise has nothing left to say that the shared value + // does not. + it("suppresses a second clear for another entry with the same URL, and allows one after it settles", async () => { + // `delay: null` drops userEvent's inter-event waits, which dominate here. + const user = userEvent.setup({ delay: null }); + renderWithMantine(); - await user.click(screen.getByText("connect")); - await waitFor(() => expect(clientInstances).toHaveLength(1)); - // The instances are typed `EventTarget`; an intersection names the - // test-only spy without erasing that (Copilot). - const client = clientInstances[0] as EventTarget & { - clearOAuthTokens: ReturnType; - }; + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + // The instances are typed `EventTarget`; an intersection names the + // test-only spy without erasing that (Copilot). + const client = clientInstances[0] as EventTarget & { + clearOAuthTokens: ReturnType; + }; - // Hold the first clear open, as a pending RFC 7009 request would. - let settle: (v: { status: string; reason: string }) => void = () => {}; - client.clearOAuthTokens.mockImplementation( - () => - new Promise((resolve) => { - settle = resolve as typeof settle; - }), - ); + // Hold the first clear open, as a pending RFC 7009 request would. + let settle: (v: { status: string; reason: string }) => void = () => {}; + client.clearOAuthTokens.mockImplementation( + () => + new Promise((resolve) => { + settle = resolve as typeof settle; + }), + ); - await clearFromSettings(user, "open-settings"); - await waitFor(() => - expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1), - ); + await clearFromSettings(user, "open-settings"); + await waitFor(() => + expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1), + ); - // Entry B: a different catalog id, the same OAuth storage key. Both clears - // route through the live client precisely because they share that key, so - // an id-keyed guard would let this second one straight through. - await clearFromSettings(user, "open-settings-b"); - expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1); + // Entry B: a different catalog id, the same OAuth storage key. Both clears + // route through the live client precisely because they share that key, so + // an id-keyed guard would let this second one straight through. + await clearFromSettings(user, "open-settings-b"); + expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1); - // Suppression is for the duration of the in-flight clear only — the key - // must be released when it settles, or the control is dead for the rest of - // the session. - await act(async () => { - settle({ status: "skipped", reason: "no_endpoint" }); - await Promise.resolve(); - }); - client.clearOAuthTokens.mockResolvedValue({ - status: "skipped", - reason: "no_endpoint", - }); - await clearFromSettings(user, "open-settings-b"); - await waitFor(() => - expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2), - ); - }, - ); + // Suppression is for the duration of the in-flight clear only — the key + // must be released when it settles, or the control is dead for the rest of + // the session. + await act(async () => { + settle({ status: "skipped", reason: "no_endpoint" }); + await Promise.resolve(); + }); + client.clearOAuthTokens.mockResolvedValue({ + status: "skipped", + reason: "no_endpoint", + }); + await clearFromSettings(user, "open-settings-b"); + await waitFor(() => + expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2), + ); + }); }); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index 66afa287..86402a82 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -557,19 +557,17 @@ describe("AppRenderer", () => { // budget that is too tight rather than of a broken observer. Raised // rather than retried: a `waitFor` that is generous costs nothing on the // passing path, since it returns as soon as the assertion holds. - await waitFor( - () => - expect(bridge.sendHostContextChange).toHaveBeenCalledWith( - expect.objectContaining({ - theme: "dark", - styles: expect.objectContaining({ - variables: expect.objectContaining({ - "--color-background-primary": "#101113", - }), + await waitFor(() => + expect(bridge.sendHostContextChange).toHaveBeenCalledWith( + expect.objectContaining({ + theme: "dark", + styles: expect.objectContaining({ + variables: expect.objectContaining({ + "--color-background-primary": "#101113", }), }), - ), - { timeout: 5000 }, + }), + ), ); } finally { getComputedStyleSpy.mockRestore(); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx index 133c8617..44b5c409 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { act, + RAF_SLACK_MS, renderWithMantine, renderWithMantineTransitions, screen, @@ -18,7 +19,18 @@ import { HEADER_ANIM_MS, ViewHeader } from "./ViewHeader"; // duration plus rAF scheduling slack, so bumping HEADER_ANIM_MS can't silently // make the settle insufficient. (This is why ViewHeader.tsx exports // HEADER_ANIM_MS — the settle window must track the animation it settles.) -const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200; +// +// The slack half is `RAF_SLACK_MS` rather than a bare `+ 200` (#2323): the two +// terms answer to different things — the first to the component, the second to +// how busy the machine is — and only the second should move when the machine +// gets busier. Naming it is what lets it move in one place, here and in the +// helper's own fallback. +// +// No `enterDelay`/`exitDelay` is passed to any of ViewHeader's four +// `Transition`s, so HEADER_ANIM_MS really is the longest JS chain. The 150ms +// stagger visible in the UI is a CSS `animation-delay` (`App.css`), and a CSS +// animation schedules no JS timer for the settle to drain. +const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + RAF_SLACK_MS; // Mock @mantine/hooks so we can control useMediaQuery results per test. const mediaQueryMock = vi.hoisted(() => ({ value: false })); diff --git a/clients/web/src/test/integration/auth/node/file-lock.test.ts b/clients/web/src/test/integration/auth/node/file-lock.test.ts index bd4d5929..ebf9cacc 100644 --- a/clients/web/src/test/integration/auth/node/file-lock.test.ts +++ b/clients/web/src/test/integration/auth/node/file-lock.test.ts @@ -7,6 +7,14 @@ * is one process-wide queue per path, so two in-process callers are ordered * before the lock ever sees them. A child process is not scaffolding here — * it is the only participant that can produce the interleaving. + * + * Four tests here raise their own budget past the integration project's 30s, + * and are among the handful in the repo that still should (#2323). What they + * wait on is not React or a renderer — it is `proper-lockfile`'s stale-lock + * timeout and a second OS process actually contending for the file, which is + * real elapsed work rather than slack. The named constants below say so at + * each site; the restatements of the project's own 30s that used to sit + * alongside them are gone. */ import { describe, @@ -28,6 +36,11 @@ import { withSecretFileLock, resetFileLockWarnings, } from "@inspector/core/auth/node/file-lock.js"; + +/** A test that waits out a full stale-lock takeover in a second process. */ +const LOCK_TAKEOVER_MS = 90_000; +/** A test that waits on cross-process contention but not on a stale window. */ +const LOCK_CONTENTION_MS = 60_000; import { FileSecretStore } from "@inspector/core/auth/node/file-secret-store.js"; import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js"; @@ -150,7 +163,7 @@ describe("withSecretFileLock across processes", () => { // …and having waited, it did not report a degraded write. expect(warnings()).toBe(""); await done; - }, 20_000); + }); it("locks a file that does not exist yet", async () => { // The first `set` on a fresh install has no `secrets.json` — and @@ -204,7 +217,7 @@ describe("withSecretFileLock across processes", () => { expect(await reader.get("srv", "env:FROM_CHILD")).toBe("1"); expect(await reader.get("srv", "env:FROM_PARENT")).toBe("2"); expect(warnings()).toBe(""); - }, 20_000); + }); it("creates the storage directory so the very first save is locked too", async () => { // `writeStoreFile` creates the parent directory, but from *inside* the @@ -232,7 +245,7 @@ describe("withSecretFileLock across processes", () => { expect(await reader.get("srv", "env:FIRST_EVER")).toBe("1"); // No degrade warning: the lock was genuinely held, not skipped. expect(warnings()).toBe(""); - }, 20_000); + }); }); describe("withSecretFileLock degrades rather than failing", () => { @@ -263,131 +276,147 @@ describe("withSecretFileLock degrades rather than failing", () => { expect(warn).toHaveBeenCalledTimes(1); }); - it("refuses the save rather than writing alongside a live holder", async () => { - // `ELOCKED` is evidence the lock is *working*, so degrading here would - // enter the exact interleaving the lock exists to prevent — and enter it - // knowing another writer is there. Held from this process, which is - // indistinguishable to `proper-lockfile` from a remote holder (it is not - // reentrant); the in-process queue is what keeps that out of the way in - // production. - // - // The wait is real: the retry budget deliberately outlasts the 10s stale - // window so a *crashed* holder resolves by takeover instead of failing - // everyone else's saves. This holder is alive and refreshing, so it never - // goes stale and the budget is spent in full. - const target = filePath(); - const lockfile = require_(LOCKFILE_MODULE) as { - lock: (f: string, o: object) => Promise<() => Promise>; - }; - // The **same** `stale` production uses, and that is not incidental: - // `isLockStale` is evaluated against the *waiter's* threshold while the - // holder refreshes on its own `stale / 2`. A holder configured looser - // (say 60s) refreshes every 30s and is therefore declared stale by a - // 10s waiter after 10s — the waiter takes over and the save succeeds, - // quietly testing the opposite of what this test claims. - const release = await lockfile.lock(target, { - realpath: false, - stale: 10_000, - }); + it( + "refuses the save rather than writing alongside a live holder", + async () => { + // `ELOCKED` is evidence the lock is *working*, so degrading here would + // enter the exact interleaving the lock exists to prevent — and enter it + // knowing another writer is there. Held from this process, which is + // indistinguishable to `proper-lockfile` from a remote holder (it is not + // reentrant); the in-process queue is what keeps that out of the way in + // production. + // + // The wait is real: the retry budget deliberately outlasts the 10s stale + // window so a *crashed* holder resolves by takeover instead of failing + // everyone else's saves. This holder is alive and refreshing, so it never + // goes stale and the budget is spent in full. + const target = filePath(); + const lockfile = require_(LOCKFILE_MODULE) as { + lock: (f: string, o: object) => Promise<() => Promise>; + }; + // The **same** `stale` production uses, and that is not incidental: + // `isLockStale` is evaluated against the *waiter's* threshold while the + // holder refreshes on its own `stale / 2`. A holder configured looser + // (say 60s) refreshes every 30s and is therefore declared stale by a + // 10s waiter after 10s — the waiter takes over and the save succeeds, + // quietly testing the opposite of what this test claims. + const release = await lockfile.lock(target, { + realpath: false, + stale: 10_000, + }); - const store = new FileSecretStore({ filePath: target }); - // One call, both assertions off the same rejection: each attempt spends - // the full retry budget, so a second would double the test's runtime to - // re-prove the same thing. - const err = await store - .set("srv", "env:MINE", "1") - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(SecretStoreUnavailableError); - expect((err as Error).message).toMatch(/was not saved/); - await release(); + const store = new FileSecretStore({ filePath: target }); + // One call, both assertions off the same rejection: each attempt spends + // the full retry budget, so a second would double the test's runtime to + // re-prove the same thing. + const err = await store + .set("srv", "env:MINE", "1") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(SecretStoreUnavailableError); + expect((err as Error).message).toMatch(/was not saved/); + await release(); - // Nothing was written behind the holder's back. - expect(existsSync(target)).toBe(false); - }, 90_000); + // Nothing was written behind the holder's back. + expect(existsSync(target)).toBe(false); + }, + LOCK_TAKEOVER_MS, + ); - it("refuses rather than degrading when a stale lock cannot be cleared", async () => { - // `acquireLock` does not only *create* directories — on finding a stale - // one it removes it and retries, and that removal can fail. A stale lock - // with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and - // treating every non-`ELOCKED` error as "locks do not work here" meant - // every Inspector on the box quietly bypassed the *same* stuck lock and - // raced its writes — while the release-failure message was telling the - // operator saves would keep failing until they cleared it. - const target = filePath(); - const lockPath = `${target}.lock`; - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.mkdir(lockPath); - await fs.writeFile(`${lockPath}/stray`, "", "utf-8"); - // Backdated so it reads as stale — which is what sends `acquireLock` down - // the remove-and-retry path rather than straight to `ELOCKED`. - const longDead = new Date(Date.now() - 60_000); - await fs.utimes(lockPath, longDead, longDead); + it( + "refuses rather than degrading when a stale lock cannot be cleared", + async () => { + // `acquireLock` does not only *create* directories — on finding a stale + // one it removes it and retries, and that removal can fail. A stale lock + // with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and + // treating every non-`ELOCKED` error as "locks do not work here" meant + // every Inspector on the box quietly bypassed the *same* stuck lock and + // raced its writes — while the release-failure message was telling the + // operator saves would keep failing until they cleared it. + const target = filePath(); + const lockPath = `${target}.lock`; + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.mkdir(lockPath); + await fs.writeFile(`${lockPath}/stray`, "", "utf-8"); + // Backdated so it reads as stale — which is what sends `acquireLock` down + // the remove-and-retry path rather than straight to `ELOCKED`. + const longDead = new Date(Date.now() - 60_000); + await fs.utimes(lockPath, longDead, longDead); - const store = new FileSecretStore({ filePath: target }); - const err = await store - .set("srv", "env:MINE", "1") - .catch((e: unknown) => e); + const store = new FileSecretStore({ filePath: target }); + const err = await store + .set("srv", "env:MINE", "1") + .catch((e: unknown) => e); - expect(err).toBeInstanceOf(SecretStoreUnavailableError); - expect((err as Error).message).toMatch(/was not saved/); - // Nothing written behind the stuck lock, and no "unprotected" warning: - // this is a refusal, not a degrade. - expect(existsSync(target)).toBe(false); - expect(warnings()).not.toContain("not protected"); - }, 90_000); + expect(err).toBeInstanceOf(SecretStoreUnavailableError); + expect((err as Error).message).toMatch(/was not saved/); + // Nothing written behind the stuck lock, and no "unprotected" warning: + // this is a refusal, not a degrade. + expect(existsSync(target)).toBe(false); + expect(warnings()).not.toContain("not protected"); + }, + LOCK_TAKEOVER_MS, + ); - it("takes over the lock of a holder that died, rather than failing the save", async () => { - // The invariant behind refusing on `ELOCKED`: refusing is only defensible - // because a *crashed* holder resolves on its own first. `RETRY` therefore - // has to outlast `STALE_MS` — if the budget were the shorter of the two, - // one Inspector killed mid-save would make every later save on the box - // fail until someone deleted the lock by hand. - // - // A dead holder is exactly a lock directory nobody is refreshing, so it - // is staged directly: no child to race, and no dependence on how quickly - // a killed process is reaped. - const target = filePath(); - const lockPath = `${target}.lock`; - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.mkdir(lockPath); - const longDead = new Date(Date.now() - 60_000); - await fs.utimes(lockPath, longDead, longDead); + it( + "takes over the lock of a holder that died, rather than failing the save", + async () => { + // The invariant behind refusing on `ELOCKED`: refusing is only defensible + // because a *crashed* holder resolves on its own first. `RETRY` therefore + // has to outlast `STALE_MS` — if the budget were the shorter of the two, + // one Inspector killed mid-save would make every later save on the box + // fail until someone deleted the lock by hand. + // + // A dead holder is exactly a lock directory nobody is refreshing, so it + // is staged directly: no child to race, and no dependence on how quickly + // a killed process is reaped. + const target = filePath(); + const lockPath = `${target}.lock`; + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.mkdir(lockPath); + const longDead = new Date(Date.now() - 60_000); + await fs.utimes(lockPath, longDead, longDead); - const store = new FileSecretStore({ filePath: target }); - await store.set("srv", "env:AFTER_CRASH", "1"); + const store = new FileSecretStore({ filePath: target }); + await store.set("srv", "env:AFTER_CRASH", "1"); - const reader = new FileSecretStore({ filePath: target }); - expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1"); - // Took the lock over — did not fall through to an unlocked write. - expect(warnings()).toBe(""); - }, 60_000); + const reader = new FileSecretStore({ filePath: target }); + expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1"); + // Took the lock over — did not fall through to an unlocked write. + expect(warnings()).toBe(""); + }, + LOCK_CONTENTION_MS, + ); - it("stays silent per the delete contract when the lock is held", async () => { - // `delete` reports nothing by contract — only `set` hard-fails — so the - // refusal above must not turn a delete into a throw. - const target = filePath(); - const store = new FileSecretStore({ filePath: target }); - await store.set("srv", "env:A", "1"); + it( + "stays silent per the delete contract when the lock is held", + async () => { + // `delete` reports nothing by contract — only `set` hard-fails — so the + // refusal above must not turn a delete into a throw. + const target = filePath(); + const store = new FileSecretStore({ filePath: target }); + await store.set("srv", "env:A", "1"); - const lockfile = require_(LOCKFILE_MODULE) as { - lock: (f: string, o: object) => Promise<() => Promise>; - }; - // The **same** `stale` production uses, and that is not incidental: - // `isLockStale` is evaluated against the *waiter's* threshold while the - // holder refreshes on its own `stale / 2`. A holder configured looser - // (say 60s) refreshes every 30s and is therefore declared stale by a - // 10s waiter after 10s — the waiter takes over and the save succeeds, - // quietly testing the opposite of what this test claims. - const release = await lockfile.lock(target, { - realpath: false, - stale: 10_000, - }); - await expect(store.delete("srv", "env:A")).resolves.toBeUndefined(); - await release(); + const lockfile = require_(LOCKFILE_MODULE) as { + lock: (f: string, o: object) => Promise<() => Promise>; + }; + // The **same** `stale` production uses, and that is not incidental: + // `isLockStale` is evaluated against the *waiter's* threshold while the + // holder refreshes on its own `stale / 2`. A holder configured looser + // (say 60s) refreshes every 30s and is therefore declared stale by a + // 10s waiter after 10s — the waiter takes over and the save succeeds, + // quietly testing the opposite of what this test claims. + const release = await lockfile.lock(target, { + realpath: false, + stale: 10_000, + }); + await expect(store.delete("srv", "env:A")).resolves.toBeUndefined(); + await release(); - // …and the entry it could not delete is still there, not half-removed. - expect(await store.get("srv", "env:A")).toBe("1"); - }, 90_000); + // …and the entry it could not delete is still there, not half-removed. + expect(await store.get("srv", "env:A")).toBe("1"); + }, + LOCK_TAKEOVER_MS, + ); }); describe("withSecretFileLock reports what it cannot clean up", () => { @@ -457,7 +486,7 @@ describe("withSecretFileLock reports what it cannot clean up", () => { // Unguarded, the winner's directory is gone. expect(existsSync(lockPath)).toBe(false); - }, 30_000); + }); it("tells the operator to clear a lock that cannot expire on its own", async () => { // `rmdir` refuses a non-empty directory — and so does stale takeover, @@ -510,5 +539,5 @@ describe("withSecretFileLock reports what it cannot clean up", () => { // that did nothing wrong. `onCompromised` has already said what happened. expect(warnings()).not.toContain("by hand"); expect(warnings()).not.toContain("Could not release the lock"); - }, 30_000); + }); }); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 9cc2b065..9b4c7cf5 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -715,6 +715,10 @@ describe("absorbFileSecretsIntoKeyring", () => { expect(warn).toHaveBeenCalledWith( expect.stringContaining("Could not lock the secrets file"), ); + // Past the integration project's own 30s deliberately, and one of the few + // sites that should be (#2323): this test waits on a second process holding + // the secrets-file lock until `proper-lockfile` gives up on it, which is + // real elapsed contention rather than slack for a loaded machine. }, 60_000); it("takes no lock when there is nothing to migrate (#2082)", async () => { diff --git a/clients/web/src/test/integration/auth/revocation-e2e.test.ts b/clients/web/src/test/integration/auth/revocation-e2e.test.ts index dfbd4d0c..646cf99b 100644 --- a/clients/web/src/test/integration/auth/revocation-e2e.test.ts +++ b/clients/web/src/test/integration/auth/revocation-e2e.test.ts @@ -96,7 +96,7 @@ describe("OAuth token revocation (RFC 7009)", () => { metadata = (await ( await fetch(`${serverUrl}/.well-known/oauth-authorization-server`) ).json()) as OAuthMetadata; - }, 30_000); + }); afterAll(async () => { await mcpServer?.stop(); @@ -105,7 +105,7 @@ describe("OAuth token revocation (RFC 7009)", () => { rmSync(storageDir, { recursive: true, force: true }); storageDir = ""; } - }, 30_000); + }); /** Run a real authorization-code exchange and return the issued tokens. */ async function authorize( diff --git a/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts b/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts index cbd06e52..353123e6 100644 --- a/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts +++ b/clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts @@ -51,12 +51,12 @@ describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => { const port = await mcpServer.start(); serverUrl = `http://localhost:${port}`; await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH }); - }, 30_000); + }); afterAll(async () => { await mcpServer?.stop(); mcpServer = null; - }, 30_000); + }); it("serves plain RFC 8414 metadata only from the OIDC path", async () => { const oidc = await fetch(`${serverUrl}${OIDC_PATH}`); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts index d7a91274..f952a930 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts @@ -70,7 +70,7 @@ describe("OAuth connect on the probing eras (#1805)", () => { await mcpServer.stop(); mcpServer = null; } - }, 30_000); + }); afterAll(() => { try { @@ -166,7 +166,7 @@ describe("OAuth connect on the probing eras (#1805)", () => { expect( (error as AuthRecoveryRequiredError).authorizationUrl, ).toBeInstanceOf(URL); - }, 30_000); + }); it(`connects on the "${era}" era once authorization completes`, async () => { const serverUrl = await startProtectedModernServer(); @@ -183,6 +183,6 @@ describe("OAuth connect on the probing eras (#1805)", () => { expect(authorizing.getProtocolEra()).toBe("modern"); expect(authorizing.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION); expect((await authorizing.listTools()).tools.length).toBeGreaterThan(0); - }, 30_000); + }); } }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 83e15acb..5c11dcb7 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -493,7 +493,7 @@ describe("modern-era negotiation (2026-07-28)", () => { await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError); await expect(aborted).resolves.toBe(true); - }, 30_000); + }); // The test above builds its own never-returning tool, which is the sharpest // way to assert on the server's abort signal but says nothing about the @@ -576,7 +576,7 @@ describe("modern-era negotiation (2026-07-28)", () => { // The handler must return promptly, saying it was cancelled — not run on to // its 30th second, which is what it did before the fix. await expect(outcome).resolves.toContain("cancelled after"); - }, 30_000); + }); it("cancels an in-flight MRTR call while its embedded request is pending", async () => { const started = await startMrtrServer(createMrtrTool()); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts index be981518..5f96b788 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts @@ -54,7 +54,7 @@ describe("InspectorClient direct mid-session OAuth", () => { await mcpServer.stop(); mcpServer = null; } - }, 30_000); + }); afterAll(() => { try { @@ -139,7 +139,7 @@ describe("InspectorClient direct mid-session OAuth", () => { expect(client.getStatus()).toBe("connected"); await client.disconnect(); - }, 30_000); + }); it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => { const staticClientId = "test-direct-step-up"; @@ -244,5 +244,5 @@ describe("InspectorClient direct mid-session OAuth", () => { expect(result.success).toBe(true); await client.disconnect(); - }, 30_000); + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts index 4cde2b6b..abc8bb47 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts @@ -104,7 +104,7 @@ describe("InspectorClient remote mid-session OAuth", () => { } remoteBaseUrl = undefined; remoteAuthToken = undefined; - }, 30_000); + }); afterAll(() => { try { @@ -206,7 +206,7 @@ describe("InspectorClient remote mid-session OAuth", () => { expect(client.getStatus()).toBe("connected"); await client.disconnect(); - }, 15_000); + }); it("recovers on reconnect when stored access token was invalidated before connect", async () => { await setupRemoteServer(); @@ -446,7 +446,7 @@ describe("InspectorClient remote mid-session OAuth", () => { await callbackClient.disconnect(); await client.disconnect(); - }, 30_000); + }); it("resumeAfterOAuth falls back to connect when remote session is dead", async () => { await setupRemoteServer(); @@ -549,7 +549,7 @@ describe("InspectorClient remote mid-session OAuth", () => { await callbackClient.disconnect(); await client.disconnect(); - }, 30_000); + }); it("dispatches authChallengeInteractive for ambient interactive recovery", async () => { await setupRemoteServer(); @@ -640,7 +640,7 @@ describe("InspectorClient remote mid-session OAuth", () => { ).toBeInstanceOf(URL); await client.disconnect(); - }, 15_000); + }); it("recovers from ambient auth notification after idle token invalidation", async () => { await setupRemoteServer(); @@ -731,5 +731,5 @@ describe("InspectorClient remote mid-session OAuth", () => { expect(client.getStatus()).toBe("connected"); await client.disconnect(); - }, 15_000); + }); }); diff --git a/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts b/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts index d9965b68..1663085e 100644 --- a/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts +++ b/clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts @@ -74,7 +74,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => { serverUrl = `http://localhost:${port}`; storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2071-")); await waitForOAuthWellKnown(serverUrl); - }, 30_000); + }); afterAll(async () => { await mcpServer?.stop(); @@ -83,7 +83,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => { rmSync(storageDir, { recursive: true, force: true }); storageDir = ""; } - }, 30_000); + }); it("advertises the non-default metadata URL on the 401 challenge", async () => { const response = await fetch(`${serverUrl}/mcp`, { method: "POST" }); @@ -179,7 +179,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => { ).toEqual([]); await client.disconnect(); - }, 30_000); + }); it("observes the SSE challenge through a caller-supplied eventSourceInit.fetch", async () => { // That explicit fetch is deliberately not proxy- or intercept-wrapped, so @@ -225,7 +225,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => { expect(observed).toContainEqual( expect.objectContaining({ resourceMetadataUrl: METADATA_URL_LITERAL }), ); - }, 30_000); + }); it("keeps the showcase config valid and carrying the custom path", () => { const configPath = fileURLToPath( diff --git a/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts b/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts index 612b4cbf..bb440fbd 100644 --- a/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts +++ b/clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts @@ -174,7 +174,7 @@ describe("web-client cancellation over a per-request stream (#2140)", () => { // `notifications/cancelled` it was free to ignore (and did), its request // signal never fired, and this promise never resolved. await expect(slow.aborted).resolves.toBe(true); - }, 30_000); + }); it("advertises the per-request stream only for streamable-http", () => { // The flag is what the SDK forks on, and the factory is where a server's diff --git a/clients/web/src/test/renderWithMantine.tsx b/clients/web/src/test/renderWithMantine.tsx index 677d9ca0..a14834fd 100644 --- a/clients/web/src/test/renderWithMantine.tsx +++ b/clients/web/src/test/renderWithMantine.tsx @@ -108,12 +108,46 @@ export function renderWithMantineTransitions( return { ...result, unmount: downgradingUnmount }; } -// Fallback settle window: ≈500ms clears a typical few-hundred-millisecond -// transition plus the two-frame rAF scheduling slack. Callers that know their -// component's animation duration should pass `settleMs` (its longest JS timer -// chain — duration plus any `enterDelay`/`exitDelay` — plus slack) rather than -// rely on this default. -const DEFAULT_SETTLE_MS = 500; +// A settle window is two terms with completely different relationships to the +// machine, and they must not be scaled as one number (#2323). +// +// The first term tracks the component: the longest JS timer chain its +// transition schedules — its duration plus any `enterDelay`/`exitDelay`. That +// is fixed by the component and load does not move it. Callers that know their +// component derive it from the component's own exported constant, which is why +// `ViewHeader.test.tsx` writes `HEADER_ANIM_MS + RAF_SLACK_MS` rather than a +// literal: bumping the animation then cannot silently outrun the settle. +// +// The second term is this one — the slack in which the rAF callbacks, the React +// commit and the terminal `setTimeout` they schedule actually have to *run*. +// That work is CPU-bound, so it is the term an oversubscribed machine eats, and +// the only term that should grow when the machine gets busier. +// +// ⚠️ Unlike a timeout, this is a fixed sleep: it always waits the full window +// and never exits early. Raising it therefore costs every *passing* run (3 call +// sites x 300ms ≈ 0.9s), which is the opposite call from the one made for the +// repo's ordinary fixed sleeps. It is justified here only because this failure +// is silent and displaced rather than loud and local: an insufficient settle +// fails nothing at its own site — the test's assertions have already passed — +// and surfaces either as a leaked timer that `setup.ts` cancels with no report +// at all, or as an uncaught post-teardown `window is not defined` failing the +// whole run from an arbitrary innocent file (#1760). Re-running localizes +// neither. Do not generalize this to the ordinary sleeps. +// +// (A condition wait is not available and should not be re-litigated: a +// completed *enter* transition leaves no DOM signal to `waitFor` on, which is +// the whole reason this is a sleep at all.) +export const RAF_SLACK_MS = 500; + +// The animation term of the fallback window, for a caller that does not know +// its component's duration. A typical Mantine transition is a few hundred ms. +const TYPICAL_ANIM_MS = 300; + +// Fallback settle window, used by `settleTransitions()` called with no +// argument. Built from the same slack constant as the derived call sites so the +// two cannot drift apart. Callers that know their component's animation +// duration should pass `settleMs` instead of relying on this. +const DEFAULT_SETTLE_MS = TYPICAL_ANIM_MS + RAF_SLACK_MS; // Set by `renderWithMantineTransitions`; consumed once by the `afterEach` below. // `armedSettleMs === null` means no real-transitions render happened this test, @@ -165,8 +199,12 @@ function resetArming() { // `setTimeout`, and the React work they schedule against the still-mounted tree // — whichever is the actual escapee, it resolves on a live component before // cleanup unmounts. Because it awaits a *real* `setTimeout`, it deadlocks under -// fake timers, so guard against that with a clear message rather than a 5s -// test-timeout hang. +// fake timers, so guard against that with a clear message rather than leaving +// it to the project's `hookTimeout` — which is what actually bounds this await, +// since the auto-settle runs in an `afterEach`. That is 30000ms +// (`vitest.shared.mts`, #2323), so without this throw a fake-timer deadlock +// would take half a minute to report something naming neither transitions nor +// fake timers. export async function settleTransitions(ms: number = DEFAULT_SETTLE_MS) { if (vi.isFakeTimers()) { throw new Error( diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index c86b3634..53e6621b 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -1,6 +1,25 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; +import { cleanup, configure } from "@testing-library/react"; + +// Testing Library's own `asyncUtilTimeout` default is 1000ms +// (`@testing-library/dom/dist/config.js`), and it governs every `waitFor` and +// `findBy*` in the unit project — 788 call sites — none of which chose it. One +// `configure` call is the whole surface, which is why it is here rather than +// argued a site at a time (#2323). +// +// 5x, because a contended happy-dom render is the worst-measured case on this +// team's machine (8 logical cores, three or four concurrent worktree sessions +// each free to run the full `local:gate`), and because once the project's +// `testTimeout` rises to 15000 this becomes the *binding* constraint on every +// async assertion in the project — the enclosing budget stops being the thing +// that cuts a wait short and this starts being it. +// +// The trade-off, stated rather than hidden: a genuinely-failing async +// assertion now takes 5s to report instead of 1s. Only failing assertions pay +// it, once each, on runs that are already the slow path. A *passing* `waitFor` +// returns the instant its callback stops throwing and is unaffected. +configure({ asyncUtilTimeout: 5000 }); // Node 22+ exposes an experimental `localStorage` placeholder that overrides // happy-dom's implementation. Without `--localstorage-file`, it's an empty diff --git a/clients/web/src/test/storybookSetup.ts b/clients/web/src/test/storybookSetup.ts new file mode 100644 index 00000000..bb49c73a --- /dev/null +++ b/clients/web/src/test/storybookSetup.ts @@ -0,0 +1,30 @@ +/** + * Setup file for the `storybook` Vitest project (#2323). + * + * It exists for one call. Storybook's instrumented Testing Library re-exports + * the same `asyncUtilTimeout: 1000` default as the unit project's, and it + * governs the 80 `waitFor` / `findBy*` sites across the story files. Those play + * functions run in a real Chromium against a real Storybook preview, so 1000ms + * is the *tighter* of the two bounds the project has — the per-test ceiling is + * 15000 (#2292) — and it is the one nobody chose. + * + * ⚠️ Two things about this file that are deliberate and easy to undo: + * + * 1. **It must not live in `.storybook/`, and must not call + * `setProjectAnnotations`.** `@storybook/addon-vitest` provisions the + * preview annotations itself, and skips doing so when it finds a setup file + * that is *both* inside `configDir` and contains that call (#1898) — so the + * old `.storybook/vitest.setup.ts` was opting out of the automatic path. + * Either half of the condition is enough to stay on it; this file misses + * both. `src/test/PreviewAnnotations.stories.tsx` asserts the provisioning + * directly, so a regression here fails a test rather than silently rendering + * every story without the Mantine decorator. + * 2. **The import is `storybook/test`, not `@testing-library/*`.** Storybook + * instruments its own copy so the interactions panel can trace each step; + * configuring a different copy would change nothing that a play function + * actually calls. + */ + +import { configure } from "storybook/test"; + +configure({ asyncUtilTimeout: 5000 }); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 97034569..1070cfcf 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -14,7 +14,11 @@ import { } from "./server/vite-base-config"; import { buildWebServerConfigFromEnv } from "./server/web-server-config"; import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { + INTEGRATION_TIMEOUTS, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; const dirname = typeof __dirname !== "undefined" ? __dirname @@ -330,6 +334,12 @@ export default defineConfig(({ command }) => { // `container.isConnected` self-checks are the real guard against a // future regression. sequence: { hooks: "stack" }, + // Shared budgets (#2323). This is the largest test surface in the + // repo — 342 files — and every one of them ran on Vitest's own + // 5000ms until now. The single site that had been patched by hand + // (`App.test.tsx`'s three-modal sequence) named that default as its + // reason for existing. + ...TIMEOUTS, }, }, { @@ -349,9 +359,12 @@ export default defineConfig(({ command }) => { include: [integrationGlob], // Integration tests spawn real HTTP/stdio servers via test-servers/, // bind sockets, run e2e OAuth flows, and exercise filesystem-backed - // storage. 30s matches the v1.5 core/vitest.config.ts. - testTimeout: 30000, - hookTimeout: 30000, + // storage. 30s matches the v1.5 core/vitest.config.ts. Now stated + // once in `vitest.shared.mts` (#2323) rather than here, together + // with the `teardownTimeout` that was left on the 10000ms default — + // teardown is where these suites unlink that filesystem-backed + // storage and reap the servers they spawned. + ...INTEGRATION_TIMEOUTS, // Inline the MCP SDK so vi.mock("@modelcontextprotocol/client") // hooks the same transformed copy that source files import. // Externalized node_modules are loaded via Node's loader and bypass @@ -384,7 +397,16 @@ export default defineConfig(({ command }) => { // (#2292). A larger ceiling does not hide a defect here: a story // that blows 15s has genuinely failed, and every assertion stays as // strict as it was. - testTimeout: 15000, + // …which is the shared `TIMEOUTS.testTimeout` today (#2323): this + // project is where the value was first chosen, and the other five + // now follow it rather than restate it. The hook and teardown + // budgets come along with it, having been left on the defaults. + ...TIMEOUTS, + // One `configure({ asyncUtilTimeout })` call, and deliberately not + // in `.storybook/` — see the file's own header for why that + // location and the absence of `setProjectAnnotations` are what keep + // the automatic preview-annotation provisioning below intact. + setupFiles: [path.join(dirname, "src/test/storybookSetup.ts")], browser: { enabled: true, headless: true, @@ -395,12 +417,15 @@ export default defineConfig(({ command }) => { }, ], }, - // No `setupFiles`: since Storybook 10.3 `@storybook/addon-vitest` + // The `setupFiles` above is the project's only one, and it + // carries no preview annotations: since Storybook 10.3 + // `@storybook/addon-vitest` // provisions the preview annotations (`.storybook/preview.tsx` plus // `@storybook/addon-a11y/preview`) itself, and *skips* doing so when - // it finds a setup file calling `setProjectAnnotations` — so the old - // `.storybook/vitest.setup.ts` was both redundant and actively - // opting out of the automatic path (#1898). A green suite doesn't + // it finds a setup file that is both inside `configDir` and calls + // `setProjectAnnotations` — so the old `.storybook/vitest.setup.ts` + // was both redundant and actively opting out of the automatic path + // (#1898). A green suite doesn't // prove the automatic provisioning works (stories rendered without // the Mantine decorator would very likely still pass), so // `src/test/PreviewAnnotations.stories.tsx` asserts it directly. diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 08d25b36..833b186b 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -29,7 +29,7 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor | Script | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (two tiers: no dependency reaching one `tsc` program from two installs skews across them, and — since #2226 — no **declared** package holds two different top-level versions across installs at all) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the five durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (two tiers: no dependency reaching one `tsc` program from two installs skews across them, and — since #2226 — no **declared** package holds two different top-level versions across installs at all), `verify:test-timeouts` (every Vitest project resolves to a budget somebody chose, both web projects configure Testing Library's `asyncUtilTimeout`, and `retry` stays unset) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web — the TUI one runs under a **pseudoterminal** and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, [#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). A fourth, **`smoke:web:tabs`**, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its `structuredContent` section, Resources reads a resource with templates listed, Prompts fetches one ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)) — asserting the `data-*` contract in [the web README](../clients/web/README.md#core-tab-automation-contract) rather than visible copy. It is **Chromium-only**: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run local:gate` and of GitHub CI. | @@ -40,6 +40,7 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. A **second tier** (#2226) runs alongside it, asking the weaker but broader question the `AGENTS.md` rule actually states: does a package this repo *declares* anywhere resolve to two versions across our installs at all? Its candidate set is every name in any install's `dependencies`/`devDependencies`/`optionalDependencies` — unioned across the root and all four clients, so a copy declared by only one of them still counts — that **more than one install holds a top-level copy of** (17 packages today). Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours. Neither tier subsumes the other — the program tier sees a copy no manifest names (`@modelcontextprotocol/sdk`, arriving through another package's `.d.ts`), while the declared tier sees a **transitive** copy no program loads (cli's `@types/node`, hoisted via `@types/express` — the case that motivated it), two **clients** disagreeing with no root copy involved (`@types/react`, web against tui), and the peer shadows `eslint`/`typescript`/`vitest` that never enter a program. Same deny-by-default and same within-a-major rule, against its own `TOLERATED_DECLARED_SKEW` — also empty. Two limits: it reads **lockfiles**, so an uncommitted hand-installed copy is invisible, and it compares only declared names, so a purely transitive package no manifest names stays the first tier's business. Runs in `validate`. +| `npm run verify:test-timeouts` | Guards the wall-clock budgets the test gates run under (#2323). The class it encodes against is a budget **nobody chose**: three of the six Vitest projects ran on Vitest's own `testTimeout: 5000` and five on its `hookTimeout`/`teardownTimeout: 10000`, sized for an idle machine rather than the one this team works on — three or four concurrent agent sessions in separate worktrees, each free to run a full `local:gate`, on eight logical cores. A correct, deterministic test cut off by such a budget fails a gate its diff did not break, which is the same "channel nobody trusts" failure [Lint has no warning tier](../AGENTS.md#lint-has-no-warning-tier) describes from the other direction; #2292, #2278, #2250, #1942 and #1742 were each an instance, found one site at a time. It asks **Vitest itself** to resolve each of the four configs and reads the number a test actually gets, rather than checking that a key is absent from a config block — which would pass just as happily on a config that had stopped being loaded. Three properties beyond the numbers: a **seventh project** with no row is an error rather than a silent skip, since an unstated budget is precisely the drift being prevented; **`retry` must stay unset**, because a retry turns a load-induced red into a silent green on the only pre-push gate here and would re-open #1596; and both web projects must `configure({ asyncUtilTimeout })`, the Testing Library half that no Vitest config can see — it defaults to 1000ms, governs 788 `waitFor`/`findBy*` sites, and becomes the *binding* bound on an async assertion once the enclosing per-test budget rises. Observed **red** against the pre-#2323 config before it was trusted. Runs in `validate`; its pure decision logic has a sibling `verify-test-timeouts.test.mjs` under `test:scripts`. | `npm run local:gate` | **Mandatory pre-push command.** `validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. A strict superset of GitHub CI — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. | | `npm run pack:verify` | Publish smoke — see [Publishing](./publishing.md). | diff --git a/package.json b/package.json index e92ef833..9c52e81e 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,10 @@ "verify:skills": "node scripts/verify-skills.mjs", "verify:skills:cli": "node scripts/verify-skills-cli.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + "verify:test-timeouts": "node scripts/verify-test-timeouts.mjs", "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared", "lint:core": "eslint \"core/**/*.{ts,tsx}\" --max-warnings 0", "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js --max-warnings 0", diff --git a/scripts/lib/browser-timeouts.mjs b/scripts/lib/browser-timeouts.mjs new file mode 100644 index 00000000..ffb7b6ac --- /dev/null +++ b/scripts/lib/browser-timeouts.mjs @@ -0,0 +1,49 @@ +/** + * Playwright wall-clock budgets for the web smokes (#2323). + * + * Before this, `smoke-web-tabs.mjs`, `smoke-web-elicitation.mjs` and + * `smoke-web-browser.mjs` carried 15 locator budgets between them across three + * independently-chosen scales, with no config anywhere to raise. They are the + * same three or four decisions repeated, so they are named once here and the + * scripts import them — the same shape `render-smoke.mjs`'s `DEFAULTS` already + * has, and for the same reason: a budget nobody can find is a budget nobody + * revisits. + * + * These are generous on purpose and cost a passing run nothing. Playwright + * budgets are ceilings on a poll, not sleeps: every one of them returns the + * instant its locator condition holds. What they have to absorb is a smoke + * running against a cold `dist/` build on a machine already carrying three + * other agent sessions' gates. + * + * ⚠️ Raising one of these does NOT make a smoke wait longer for a server that + * is never coming: the launcher-death race in `smoke-web-browser.mjs` and the + * `waitForStage` diagnostics in the other two are what report that, and they + * report it with a cause rather than a timeout. + */ + +export const BROWSER_TIMEOUTS = Object.freeze({ + /** + * Load the page, or act on a control the app is already showing — a click on + * a rendered tab or list row, a wait for the first meaningful frame. What it + * absorbs is the browser's own scheduling under load, not a server. + */ + ui: 30_000, + /** + * Wait for something that only appears after a round trip to an MCP server: + * an elicitation modal, a tool result panel. Longer than `ui` because a test + * server has to boot, negotiate and answer before the DOM can change. + */ + roundTrip: 45_000, + /** + * Wait on a sub-element of something already on screen — a row inside a + * populated panel, a section of an open accordion. The enclosing wait has + * already paid for the round trip, so this only covers the render. + */ + nested: 15_000, + /** + * A wait whose expiry is caught and ignored, so it must stay short: it is + * pure added latency on every passing run. `networkidle` is the only one — + * the Google Fonts request may never idle on a restricted network. + */ + bestEffort: 5_000, +}); diff --git a/scripts/lib/browser-timeouts.test.mjs b/scripts/lib/browser-timeouts.test.mjs new file mode 100644 index 00000000..f646e779 --- /dev/null +++ b/scripts/lib/browser-timeouts.test.mjs @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs"; + +test("every budget is a positive finite number of milliseconds", () => { + const entries = Object.entries(BROWSER_TIMEOUTS); + assert.ok(entries.length > 0); + for (const [name, ms] of entries) { + assert.equal(typeof ms, "number", `${name} is not a number`); + assert.ok(Number.isFinite(ms) && ms > 0, `${name} is not a usable budget`); + } +}); + +test("the object is frozen, so a smoke cannot mutate a shared budget", () => { + assert.ok(Object.isFrozen(BROWSER_TIMEOUTS)); +}); + +test("the budgets are ordered by what each one has to absorb", () => { + // Not decoration: the ordering IS the semantics documented on each key. A + // `nested` wait that outlived its enclosing `roundTrip` one, or a + // `bestEffort` wait long enough to be felt on every passing run, would mean + // the names had stopped describing the values. + assert.ok(BROWSER_TIMEOUTS.bestEffort < BROWSER_TIMEOUTS.nested); + assert.ok(BROWSER_TIMEOUTS.nested < BROWSER_TIMEOUTS.ui); + assert.ok(BROWSER_TIMEOUTS.ui < BROWSER_TIMEOUTS.roundTrip); +}); diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 35018272..32355c48 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -64,6 +64,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { BROWSER_TIMEOUTS } from "./lib/browser-timeouts.mjs"; import { attachPageDiagnostics, loadBrowser, @@ -135,7 +136,7 @@ try { // authenticates without a query param. const response = await page.goto(server.baseUrl, { waitUntil: "domcontentloaded", - timeout: 30_000, + timeout: BROWSER_TIMEOUTS.ui, }); if (!response || !response.ok()) { throw new Error( @@ -145,12 +146,12 @@ try { // First meaningful frame: the always-present "Add Servers" control. await page .getByRole("button", { name: /Add Servers/ }) - .waitFor({ state: "visible", timeout: 30_000 }); + .waitFor({ state: "visible", timeout: BROWSER_TIMEOUTS.ui }); // Settle window: let lazily-evaluated chunks that throw a tick after first // paint surface before we assert a clean boot. networkidle is best-effort // (the Google-Fonts request may never idle on a restricted network). await page - .waitForLoadState("networkidle", { timeout: 5_000 }) + .waitForLoadState("networkidle", { timeout: BROWSER_TIMEOUTS.bestEffort }) .catch(() => {}); await delay(500); }; diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 6cbeaf9a..abeb4227 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -57,6 +57,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { join, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; +import { BROWSER_TIMEOUTS } from "./lib/browser-timeouts.mjs"; import { attachPageDiagnostics, loadBrowser, @@ -203,13 +204,16 @@ async function runTool(page) { // The main-view tabs are a Mantine SegmentedControl: a visually-hidden radio // plus a sibling