Commit graph inspector/clients
Author SHA1 Message Date
cliffhall
467c97307e fix: charge the fallback SKILL.md read to the catalog byte budget; review round 1 (#2294)
- A "dynamic" skill's only read is the fallback SKILL.md fetch, which was not
  charged, so a catalog of dynamic skills never advanced catalogBytes and a
  configured byte budget bounded nothing. Charge it like a manifest read.
- Add verifySkills cases for a configured byte budget and for dynamic skills.
- Wire onSkillCatalogLimitChange into the ServerSettingsForm story.
- Reword the new field descriptions, JSDoc and config table rows; rename the
  route test to cover every invalid value it sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:25:19 -04:00
cliffhall
eb41185c37 feat: make the skills verification catalog budget configurable per server (#2294)
Adds a Skills section to Server Settings with Maximum Number of Skills and
Maximum Catalog Size fields, defaulting to SKILL_MAX_CATALOG_SKILLS (256) and
SKILL_MAX_CATALOG_BYTES (64 MiB). The values persist as skillCatalogMaxSkills /
skillCatalogMaxBytes on the server's mcp.json entry (omitted at the default),
are validated as positive integers by the /api/servers routes, and are read by
verifySkills through getServerSettings(), so the CLI's --verify and the TUI
Skills pane both honor them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 23:54:29 -04:00
Cliff Hall
3e728a3b8c
Merge pull request #2363 from modelcontextprotocol/v2/fix/2028-stdio-delayed-responses
fix: reset relay request-response waits on progress notifications (#2028)
2026-09-13 21:50:36 -04:00
cliffhall
516531677e test: drop an unnecessary double cast in the relay progress test helper (#2028)
The backendHoldingSend fetch mock is already declared with vi.fn<typeof fetch>(),
so the trailing `as unknown as typeof fetch` was redundant and tripped the
repo's no-double-cast rule. Return the typed mock directly; the test project
typechecks without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:38:21 -04:00
cliffhall
fb28b0fe64 test: address Copilot review — tighten progress-token guard and de-flake relay tests (#2028)
- progressTokenOf now delegates to the SDK's ProgressTokenSchema instead of a
  bare typeof check, so fractional and past-MAX_SAFE_INTEGER numbers are
  rejected (matching isProgressToken in inspectorClient.ts); tests cover both.
- Browser relay timeout tests use fake timers with advanceTimersByTimeAsync and
  flush the SSE consumer between pushes, replacing real-sleep windows that a
  loaded runner could race (#1596).
- The log-only timeout test holds and awaits its rejection rather than
  swallowing it in an empty catch.
- progressToken.test.ts gains a file header; the remote-session malformed-token
  fixture uses a single-cast helper instead of `as unknown as`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:30:29 -04:00
cliffhall
167381174f test/docs: address Copilot review round 1 (#2320)
- Assert headersToServerSettings carries DEFAULT_CONNECTION_TIMEOUT_MS.
- Type the fake transports in inspectorClient.test.ts as the SDK's
  Transport via two helpers instead of an as-unknown-as double cast; the
  two pre-existing copies of the same fakes in that describe block use the
  helpers too.
- Bring serverList.ts's converter comments and specification/v2_servers_file.md
  in line with the new contract: 30 s default, explicit 0 persisted, and
  requestTimeout's 0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4h8Xa6Ed9zwtv5ojX35MT
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:16:23 -04:00
cliffhall
cd31d76371 fix: reset the relay's request-response waits on progress notifications (#2028)
The web client drives its MCP Client through a two-hop relay
(RemoteClientTransport -> Node backend -> upstream transport). The relay
added two request-response waits that the direct transports don't have —
the backend's RemoteSession.waitForRequestResponse and the browser's
RemoteClientTransport.waitForSseResponse — and both were hardcoded to 60s
and blind to notifications/progress. The SDK Client's own request timeout
resets on progress (resetTimeoutOnProgress, which the Inspector enables),
but the relay's progress-blind waits fire first and shadow it, so a
long-running tool that reports progress is cancelled at 60s on the web
client alone while it completes over the CLI/TUI and other MCP clients.

Reset both relay waits on a notifications/progress whose progressToken
matches the pending request id, mirroring the SDK's resetTimeoutOnProgress.
A shared browser-safe progressTokenOf helper extracts the token. Log
messages (notifications/message, i.e. ctx.info) deliberately do not reset
the deadline, matching the SDK and every other client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:13:30 -04:00
cliffhall
e641edeeab feat: bound connect attempts with a 30 s default connectionTimeout (#2320)
connectionTimeout defaulted to 0, which InspectorClient.connect() read as
"no bound at all" — so out of the box nothing the Inspector owns bounded a
connect attempt, and the only thing ending a hung handshake was the SDK's
60 s per-request timeout on initialize, reporting "Request timed out".

- Add DEFAULT_CONNECTION_TIMEOUT_MS (30000) to core/mcp/types.ts. An absent
  value resolves to it on the client, on disk read, in the web form draft,
  and in the CLI's header-only settings shell. An explicit 0 keeps its
  documented meaning (no timeout; --connect-timeout 0) and is now persisted
  so the opt-out round-trips; the default is the omit-sentinel, as for
  taskTtl.
- Key the post-timeout transport teardown to the timer actually firing, not
  to connectionTimeout > 0 — otherwise a non-zero default would tear down
  every failed connect, including a recoverable 401 whose transport the
  auth recovery holds open.
- The timeout message names the bound and where to raise it: "Connection
  timed out after N ms. To accommodate a slower server, you may increase
  the timeout value in Server Settings."
- The CLI's UNREACHABLE classifier now matches that message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4h8Xa6Ed9zwtv5ojX35MT
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 20:54:23 -04:00
cliffhall
b0391561a1 docs: address Copilot review round 1 (#1745)
- requestAppElicitation.ts, appElicitation.ts, docs/test-servers.md:
  ext-apps 2.0.0 does not "predate" #733 (the PR is from July; the
  release from September) — it simply does not include it. Reworded as
  omission, and the version the appElicitation.ts JSDoc names is now
  2.0.0 rather than 1.7.5.
- core/mcp/types.ts: the public `AppRendererClient` JSDoc described the
  app-renderer proxy; it is the real SDK `Client`, handed out only while
  connected, since #1745 removed the proxy.
- appCapabilities.test.ts: the two comments verified against 1.7.5 now
  cite 2.0.0, which the test runs against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 19:42:48 -04:00
cliffhall
a42d7fd99e refactor: drop the ext-apps SDK-v1 shims now that 2.0.0 peers on SDK v2
Every site tagged `ext-apps#702` carried a workaround for ext-apps
peering on the v1 SDK while the Inspector runs v2. With 2.0.0 the
package's `AppBridge` extends the v2 `Protocol`, takes the v2 `Client`
and `Implementation` directly, and registers its list-changed forwarding
on that client with `setNotificationHandler("notifications/…", handler)`,
so:

- core/mcp/apps.ts: `getToolUiResourceUri(tool)` takes the v2 `Tool`
  without a cast; `UiMetaShape` is replaced by ext-apps' own
  `McpUiToolMeta` / `McpUiResourceMeta`, which resolve under NodeNext
  since ext-apps#705. `AppInfo.csp` / `.permissions` / `.visibility`
  take the named types too.
- core/mcp/inspectorClient.ts: `getAppRendererClient()` returns the SDK
  client itself; the Proxy that translated the 1.x schema-first
  `setNotificationHandler(Schema, handler)` into the method-string form,
  its `notificationMethodFromSchema` helper and its cached field are
  gone. `AppRendererClient` is now `Client` rather than `unknown`.
- createAppBridgeFactory.ts: `new AppBridge(client, HOST_INFO, …)` with
  no `as unknown as`; `resolveSourceUri` passes the tool straight through.
- AppRenderer.tsx: `sendToolResult(result)` — its params ARE the v2
  `CallToolResult` now.
- requestAppElicitation.ts: the double cast on the `elicitation/create`
  send goes too — v2 `Protocol.request` takes the standard `Request`
  shape, which `ElicitRequest` is. The helper itself stays: ext-apps#733
  is not in 2.0.0.
- core/mcp/extensions.ts: `MCP_APP_MIME_TYPE` re-exports ext-apps'
  `RESOURCE_MIME_TYPE` from `/app-bridge`; `UI_EXTENSION_KEY` stays
  hardcoded because `EXTENSION_ID` is still `/server`-only in 2.0.0.

Tests: the Proxy-translation test is removed with the Proxy; the
`getAppRendererClient` tests assert the real `Client` instance and that
disconnect withdraws it; the `useInspectorClient` sentinel is a real
(unconnected) `Client`. Comments that cited ext-apps 1.7.5 now cite 2.0.0
where the fact still holds (#733 still unreleased).

Closes #1745
Refs #2309

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 19:21:06 -04:00
Cliff Hall
14661b1a94
Merge pull request #2353 from modelcontextprotocol/v2/fix/2350-skillsscreen-geometry-second-state
fix: sample the SkillsScreen geometry only once the Frontmatter highlighter has landed
2026-09-13 12:59:22 -04:00
cliffhall
274e3adca4 fix: sample the SkillsScreen geometry only once the Frontmatter highlighter has landed
The "Long Skill Document" story compared the section heights before and
after a collapse-then-reopen of the Skill Resource panel, and #2291 wrapped
the second sample in a `waitFor` on the reading that the first was fine and
the second was taken mid-layout. It was not: `[…, 155, 285]` is the layout
with the Frontmatter section showing `CodeHighlight`'s plain `Code`
placeholder (a 57px `<pre>`), and `[…, 178, 262]` is the same layout once
the lazily imported Prism runtime and YAML grammar have landed and the
highlighter has replaced it (an 80px `<pre><code>`). Both are settled; the
section's content changed between the two samples, so no retry converges.

Whether the swap has happened when the story reaches its first sample
depends on how long those dynamic imports take. On a quiet machine an
earlier story in the file has already loaded them; under the gate's load
they land between the samples. Running the story on its own, so the cache
is cold, reproduces the failure deterministically: 3/3 red with the exact
four numbers from #2350, 3/3 green with this change, 3/3 red reverted. The
whole storybook project under a concurrent unit-suite load (load average
38-51) is 3/3 green with it.

The wait's budget is stated at the site: three dynamic imports through the
Vite server every story iframe shares measure 100-150ms cold on an idle
server and exceeded Testing Library's 1000ms default under load, so it
takes the same 10s the Ace-worker wait in `JsonObjectInput.stories.tsx`
carries, under the project's 15s `testTimeout`.

Closes #2350

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 12:41:20 -04:00
cliffhall
f8536cad5d docs: address Copilot review round 1
The 5000 arms of #2335's re-run each had exactly one red test — the
asyncUtilTimeout guard refusing the raised value — so "0 failures in
every arm" was a misstatement; both comments now say so. And the
AGENTS.md rule still gave the expiring-wait mechanism as the reason the
pin was measured worse, which the corrected record contradicts; it now
cites both measurements and keeps the mechanism as the hazard the rule
guards against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 12:19:26 -04:00
cliffhall
4e19598d6b docs: stop justifying the asyncUtilTimeout pin by pointing at #2335
Both web test-setup comments explained the 1000ms pin by saying the
tests that let a Testing Library wait expire "are the real defect and
are #2335; revisit when they are gone." #2335 found there are no such
tests, and its interleaved three-arm re-run showed a raise to 5000 is a
no-op on the passing path. The comments now record that finding and
keep the pin on the measured ground: raising buys nothing, so the value
stays until a measurement says otherwise.

Comment-only; the pin and the runtime assertion are unchanged.

Closes #2347

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 11:55:42 -04:00
cliffhall
fe8b039030 chore: run each client's test suite once in local:gate, not twice (#2341)
`local:gate` ran every client's unit suite bare inside `validate` and
again instrumented inside `coverage` — ~80s of a ~370s run re-executing
exactly the files the coverage pass runs a few minutes later. CI pays
nothing for that duplication because its two jobs run in parallel; the
gate runs serially, so it paid all of it.

The gate's first stage is now `local:validate`: the same `validate:guards`
and `validate:core`, then each client's new `check` script (`format:check`
+ `lint` + `typecheck` + `build`) instead of its `validate`, which is now
literally `check && test`. `npm run validate` and both CI jobs are
unchanged; `local:validate` sits in the `local:` namespace so the
workflow guard keeps it out of CI by construction.

The "strict superset" claim in AGENTS.md is reworded to what is now
true — every CI *check*, not every CI *invocation* — with the reasoning:
`@vitest/coverage-v8` reads V8's precise-coverage profiler and rewrites
no source, so the instrumented pass runs byte-identical code and is only
slower, the stricter direction for the failure class this repo sees.

Measured on the same worktree: 370s -> 262s wall (EXIT=0 both), the
four removed legs summing to 78.5s in the baseline run's own profile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 19:53:08 -04:00
cliffhall
86e6774a9a fix: address Copilot review round 1 — move the Storybook optimizeDeps into a tested helper
The storybook project's optimizeDeps now comes from
`getStorybookOptimizeDeps()` in `vite-base-config.ts`, beside the dev-server
helper it mirrors, so the `force` flag and the base include/exclude it carries
are asserted by `vite-base-config.test.ts` rather than living only in an
inline object a config refactor could drop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 19:24:44 -04:00
cliffhall
5b9405fe8c fix: force the Storybook dep pre-bundle on every run instead of trusting the cache
Vite keys the Storybook project's dep cache on the lockfile and the config,
never on what the story graph imports. After a story or component gains an
import of an already-installed package, the cached bundle is still "valid",
Vite skips the scan, and the first request for the new package re-runs the
optimizer mid-run: the browser hash is bumped, a full-reload is sent that the
Vitest tester iframes ignore, and the `@storybook/react` renderer already
loaded in a live iframe keeps the old `?v=` for its lazy
`import("@storybook/react-dom-shim")`, so every story that iframe renders from
then on fails with "Failed to fetch dynamically imported module". The next run
is green because the cache has caught up.

`optimizeDeps.force: true` on the storybook project discards the cache up
front so the scanner crawls every story entry before the browser opens — the
cold path CI already takes on every PR. Measured: stale-cache 6/6 red on the
baseline, 6/6 green with the fix, 3/3 red reverted; ~0.8s per run.

Closes #2340

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 19:11:08 -04:00
cliffhall
69e6097b68 refactor: assert retry and asyncUtilTimeout at runtime, delete the source scanner
Round 8 of the review asked for an AST. That was the right diagnosis of the
wrong problem: the question was never which parser to use, it was why a
test-timeout guard was parsing JavaScript at all.

It got there one reasonable step at a time. The guard enforces two rules no
Vitest config can report — a per-test `retry`, and Testing Library's
`asyncUtilTimeout` — so it read them out of source text, and the review found a
new valid spelling it missed in five consecutive rounds: a trailing comment,
nested parens in `it.each([makeCase()])`, a member-access prefix, the shorthand
`{ retry }`, an aliased import, a non-literal title. Each fix was correct and
each made it more parser-shaped, until it carried eight helpers doing quote
tracking and bracket balancing.

Both rules are observable at runtime, exactly:

- `vitest.setup.shared.mts` is loaded by all six projects and reads
  `ctx.task.retry` — the value Vitest resolved for the test. That covers
  strictly more than the scan did: a per-test option, a `describe` option
  inherited by children, a project-level setting, and a `--retry` flag typed at
  a command line, which the scan could never see since it only read npm scripts.
- `clients/web/src/test/asyncUtilTimeout.test.ts` asserts
  `getConfig().asyncUtilTimeout`, the value this project's own waitFors read.

Neither has a spelling to anticipate, so neither can be outrun by a new one.
Both were observed firing before being trusted.

The guard keeps only what it can answer by asking Vitest: resolved budgets,
project-level retry, config discovery, and that each project still loads the
setup file — the one thing about the runtime assertion a config CAN report.
868 lines to 361, every scanner helper gone, and 33 of 48 unit tests deleted
because they pinned edge cases of a thing that no longer exists.

storybookSetup.ts goes with it. At the library's own value its configure() call
was a no-op, and the assertion is what pins the number.

AGENTS.md gains the general rule: a rule about what the code DOES is asserted at
runtime, not read out of the source — reach for a verify:* guard when the
question is about configuration, which a tool can be asked to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 15:58:19 -04:00
cliffhall
1cc1b48c30 fix: address Copilot review round 3 (four suppressed findings)
No inline comments this round; all four came from the suppressed block.

The AppRenderer one is a regression I introduced by combining two changes that
were individually right. #2323 deleted that `waitFor`'s `{ timeout: 5000 }` as a
budget nested inside an equal one, which it was — and then round 2 pinned
asyncUtilTimeout at 1000, which is the exact default the site's own comment
records failing twice. The raise is restored: the enclosing budget is 15000 now,
so it is observable rather than unwinnable, and the value it beats is a measured
failure rather than a library default.

The guard could approve a configure() imported from the wrong Testing Library
copy — `cleanup` from @testing-library/react and `configure` from
@testing-library/dom satisfied two independent regexes while configuring a copy
no test calls. It now resolves the binding `configure` has in the import from
the required module, follows an alias, and reads asyncUtilTimeout out of that
call rather than out of the file. Three regression tests, including the split
import and a decoy constant elsewhere in the module.

secret-store-selection's 60s exception is hoisted to
LOCK_RETRIES_EXHAUSTED_MS, matching file-lock.test.ts, which bounds the same
wait — the new rule asks for named as well as justified.

And the testing skill still prescribed `HEADER_ANIM_MS + 200`, so a test written
by following it would have bypassed the shared slack constant this change
introduced. It now prescribes `HEADER_ANIM_MS + RAF_SLACK_MS` and says why the
two terms are separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 12:02:57 -04:00
cliffhall
eff0368491 fix: address Copilot review round 2, and drop the asyncUtilTimeout raise
The inline finding was a real bug: main() never called the discovery check, so
the deny-by-default claim rested on test:scripts happening to run the same
comparison — a different command, which `npm run verify:test-timeouts` does not
invoke. `failures` is now seeded from it, and a probe config under clients/ was
observed to fail the guard.

Three suppressed findings, all correct. The afterEach in oauth-interactive
restated the shared hookTimeout and labelled it as a round trip, when it only
stops a server and unlinks a file; it now carries no budget. The file-lock
constants were named for the opposite of what they bound — 90s covers retries
running out against a holder that never yields, 60s covers the stale-lock
takeover that succeeds — so they are LOCK_RETRIES_EXHAUSTED_MS and
STALE_LOCK_TAKEOVER_MS. The browser-timeouts header said 15 budgets; it is 19.

Separately, and more importantly: the asyncUtilTimeout raise is reverted, on
measurement. Verifying round 2 turned up intermittent 15s timeouts across
unrelated unit files, so three arms of three full unit runs were taken in this
worktree at comparable load — unmodified v2/main 3/3 green, this change with
5000 producing 1, 4 and 0 timing-out files, and the same change with that one
line reverted 3/3 green. A wait that is MEANT to expire spends its whole budget
on the happy path, so a 5x raise is a 5x cost on exactly those tests and
saturates the worker pool, starving tests that were never slow.

Both web setup files therefore state asyncUtilTimeout at the library's own
value rather than raising it, which is what #2323's rule actually asks for: a
budget nobody chose is the defect, not a budget that is small. The tests that
assert absence by letting a wait expire are the real problem and are #2335.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 11:49:30 -04:00
cliffhall
7ee8a14e56 fix: address Copilot review round 1 on #2323
All nine findings taken.

Two budgets I had lowered are restored as named exceptions rather than folded
into the shared value. cli's e2e child timer goes back to 15s and the two tests
that spawn it get E2E_SPAWN_MS = 25s, so the bound carrying the diagnostic still
wins without taking load tolerance off the one suite that spawns a real process.
App.test.tsx's three-modal sequence keeps THREE_MODAL_SEQUENCE_MS = 20s — a
value chosen against an observed failure, which the shared ceiling would have
replaced with a general one.

Three real false-positive paths in the guard are closed. It now discovers every
Vitest config under clients/ and fails on one it is not configured to check —
without which "a project with no row is an error" was vacuous, since an
undiscovered config yields no project to reject. It strips comments before
looking for configure() and requires the exact expected value, so neither a
commented-out call nor asyncUtilTimeout: 1 passes. And it compares each
resolved project's setupFiles against the site it claims to configure, so
deleting a setupFiles entry can no longer leave both projects reported as
configured while Testing Library silently reverts to 1000ms. Each has a test,
and the setupFiles check was observed to fire against a real deletion.

Three stale comments the change had invalidated: main.yml still counted four
durable guards, PreviewAnnotations.stories.tsx still said the storybook project
had no setupFiles, and the new AGENTS.md rule read as though no Group F row had
been settled when this PR settles F6. browser-timeouts.test.mjs gains the
purpose header this repo's files carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 10:46:28 -04:00
cliffhall
58e53d5a92 fix: state every test-gate timeout instead of inheriting a library default
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 08:32:34 -04:00
Cliff Hall
336e045210
Merge pull request #2326 from modelcontextprotocol/v2/fix/2305-cimd-loopback-http
fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement
2026-09-11 10:22:31 -04:00
cliffhall
c3d9c665a6 fix: carry the value convention to the badges, and pin the layout it claims
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 10:07:22 -04:00
cliffhall
664c9f8800 fix: group Connection Info's full-width OAuth fields above the token rows
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 09:56:33 -04:00
cliffhall
82b13a1a12 fix: bold the labels, not the values, and give Connection Info's URLs full width
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 09:30:17 -04:00
cliffhall
cf856c740c fix: do not await the discarded stream's cancel in /api/fetch (#2319 round 26)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 08:47:01 -04:00
cliffhall
b4c02865e4 fix: do not await the release on a cancellation path (#2319 round 25)
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 08:33:18 -04:00
cliffhall
78f82475c4 fix: stop the drain and release both tee branches on timeout (#2319 round 24)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 08:19:57 -04:00
cliffhall
1e501fa5cb fix: redact sensitive query values from timeout diagnostics (#2319 round 23)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:57:38 -04:00
cliffhall
45d87911eb fix: release the discarded discovery response on every throw (#2319 round 22)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:42:17 -04:00
cliffhall
bb01c1e73b fix: bound the body by draining a clone, not by rebuilding (#2319 round 21)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:25:56 -04:00
cliffhall
e71b8b9dfb fix: never exempt a form-encoded request (#2319 round 20)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:10:00 -04:00
cliffhall
42339ff4a4 fix: narrow the exemption per transport (#2319 round 18)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:40:07 -04:00
cliffhall
5a1c157a79 fix: exempt MCP traffic by origin, not path (#2319 round 17)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:24:24 -04:00
cliffhall
e234343702 fix: cancellable compat probes and a validated budget (#2319 round 16)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:06:36 -04:00
cliffhall
93965936ba docs: describe the shipped timeout architecture (#2319 round 14)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:42:00 -04:00
cliffhall
de6960ec5a fix: make the proxy deadline per-request, not unconditional (#2319 round 13)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:24:24 -04:00
cliffhall
234d0a9ac4 fix: preserve Response enumerability, refresh transport docs (#2319 round 12)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:07:31 -04:00
cliffhall
f491c7fc9a feat: bound SDK-internal OAuth on the transport chain too (#2319 round 11)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:49:05 -04:00
cliffhall
19718c5402 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:31:36 -04:00
cliffhall
baefc96806 fix: carry the proxy deadline's identity, correct the rounding note (#2319 round 9)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:16:15 -04:00
cliffhall
a452305204 fix: cancel the discarded proxy stream, refresh revocation docs (#2319 round 8)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:00:44 -04:00
cliffhall
8baa1216fd test: pin the CIMD form copy; narrow the "no exemption" claim in the docs
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:48:54 -04:00
cliffhall
f3e4023e35 fix: sanitize rebuilt headers, refresh stale docs, cover the route (#2319 round 7)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:43:57 -04:00
cliffhall
d848300077 fix: enforce the OAuth deadline across the proxy hop (#2319 round 5)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:23:26 -04:00
cliffhall
a21fd0c161 docs: state the exemption's canonicalization contract, and fix the smoke recipe's cwd
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:12:07 -04:00
cliffhall
42139c2d23 fix: put the OAuth deadline inside the compat wrapper (#2319 round 3)
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:02:37 -04:00
cliffhall
2710c98681 fix: address Copilot review round 2 on #2319
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 02:44:43 -04:00
cliffhall
a1cad93ed9 fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 02:31:47 -04:00