Commit graph inspector/core
Author SHA1 Message Date
cliffhall
f3e0edd257 docs: describe DirectoryReadResultSchema as the shape both eras share (#2374 review)
Its doc comment still called it the legacy result, though both eras now
parse with it once the codec has removed resultType.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 09:25:16 -04:00
cliffhall
2243849711 docs: bring comments and docs in line with -32021 and the Skills registry entry (#2374 review)
Round 2 found text the PR's own changes made stale: the fixture's
option doc, handler summary and repro step still promised -32601 on
modern; the core skills.ts header still said Skills must never be in
ADVERTISABLE_EXTENSIONS; the listSkills and getSkillResult comments
still said the codec validates none of a skills result, or that the
result arrives as the server sent it; and two tests and a docs step
still described Tasks as the only registry extension. The backfill
test also now asserts the Skills declaration is on by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 09:07:58 -04:00
cliffhall
1d765b8af6 fix: declare the skills extension in the Inspector's client capabilities (#2373)
SEP-2133 negotiates an extension from both sides, and a strict server
refuses skills/* to a client that did not declare
io.modelcontextprotocol/skills. The Inspector called those methods
without ever declaring it. Add it to ADVERTISABLE_EXTENSIONS, on by
default, which also gives it a Server Settings toggle.

Give the composable test server skillsRequireClientExtension and two
per-era strict showcase configs, so a strict server is reproducible here.

Writing the modern-era test exposed two defects the existing suite hid:
its "modern" cases set protocolEra on the transport config, which
InspectorClient does not read, so they connected on legacy; and once
they really ran modern, every skills result failed, because the modern
schemas required resultType, which the SDK codec checks and then lifts
off before a caller schema runs. Connect with versionNegotiation and
assert the era, and drop the resultType requirement (and the two modern
schema variants it was the only difference in).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 08:36:37 -04:00
cliffhall
860b8d27ad Merge remote-tracking branch 'origin/v2/main' into v2/fix/2297-401-response-body
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
2026-09-14 01:47:25 -04:00
Cliff Hall
2f3099f764
Merge pull request #2367 from modelcontextprotocol/v2/feat/2294-skills-catalog-budget-settings
feat: make the skills verification catalog budget configurable per server (#2294)
2026-09-14 01:40:52 -04:00
cliffhall
1fd1b15152 fix: enforce the challenge body cap per byte and never log cut-off JSON (#2297)
Copilot review round 2:

- Slice each chunk to the remaining budget, so one oversized chunk can no
  longer carry the recorded body past CHALLENGE_BODY_MAX_BYTES.
- Drop the long-lived-stream exclusion from the challenge branch. It guards
  an unbounded read and this one is bounded, so a 401 to an SSE GET now
  records its body too.
- Keep truncation out of the string that gets redacted. A cut-off body that
  looks like JSON cannot be redacted (redactBody only masks JSON that
  parses), so it is replaced by WITHHELD_TRUNCATED_JSON_BODY. Cut-off form
  and plain-text bodies are redacted, then marked [truncated].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 01:34:38 -04:00
Cliff Hall
634b243994
Merge pull request #2372 from modelcontextprotocol/v2/feat/2208-protocol-era-flag
feat: add --protocol-era launch flag for ad-hoc servers
2026-09-14 01:32:34 -04:00
cliffhall
621b712229 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2294-skills-catalog-budget-settings
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 01:22:26 -04:00
cliffhall
4b6d77dbd8 Merge origin/v2/main into v2/feat/2208-protocol-era-flag 2026-09-14 01:12:42 -04:00
cliffhall
bee2b3f463 Merge remote-tracking branch 'origin/v2/main' into v2/fix/2295-pathless-skill-uri 2026-09-14 01:05:21 -04:00
cliffhall
a7c4c5260e fix: bound the 401/403 body read so an unending body is released (#2297)
Copilot review round 1. With the tracker below the auth-challenge
interceptor, its background clone kept draining a 401 body after the
interceptor cancelled its own branch — so a body that never ended left the
connection live. Read a 401/403 body up front instead, capped at 64 KiB and
2s, and cancel the clone's reader when either bound is hit so the
interceptor's cancel releases the source. The body now lands on the entry
itself before the challenge is thrown.

Also cover the web path end to end: a streamable-http 401 on
/api/mcp/send reaches the event stream with its status and body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 01:01:15 -04:00
cliffhall
940c8479a1 fix: stop reading skills once the byte budget is reached, not only exceeded; review round 2 (#2294)
The byte side of the catalog budget compared with `<=`, so a run whose charged
bytes exactly equalled the limit still read one more skill. Use a strict `<`,
matching the skill count and the setting's description. The skill whose reads
cross the limit is still reported in full.

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:44:31 -04:00
cliffhall
af4b574234 fix: record an intercepted 401's status, headers and body (#2297)
The network tracker wrapped the auth-challenge interceptor, so a 401/403
never reached it as a response: the interceptor cancelled the body and
threw, and the tracker logged only the thrown error. NetworkEntry renders
its response section only when a status is present, so the body — and the
WWW-Authenticate resource_metadata and scope — were never shown.

Move the tracker below the interceptor so it records the real response and
clones its body before the interceptor cancels it, and stop awaiting that
cancel: with the tracker's clone as the other tee branch, the cancel settles
only once the clone reads to the end, which would hang on an unending body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:25:19 -04:00
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
195f209359 fix: require an authority for the path-less root form (#2295)
`skill:`, `mailto:` and `skill://` also parse to an empty pathname, with an
empty host, so an empty path alone accepted authority-less URIs the guard
exists to reject. Accept the empty path only when a host is present
(Copilot review round 1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:24:10 -04:00
cliffhall
55db703196 feat: add --protocol-era launch flag for ad-hoc servers (#2208)
An ad-hoc --server-url / command launch always negotiated the legacy era,
because protocolEra was only settable per server in mcp.json. A wrapper
that mints a short-lived bearer token therefore had to write that token to
disk to exercise a server under auto or modern.

--protocol-era <legacy|auto|modern> now feeds settings.protocolEra in all
three clients through a shared parseProtocolEra coercer:

- CLI/TUI overlay it in loadServerEntries alongside --header, onto an
  ad-hoc target and onto a --catalog/--config entry for that run.
- Web stamps it on the in-memory ad-hoc entry, which the backend lifts
  into settings like headers; as with --header it is rejected alongside
  --catalog/--config, but unlike --header it applies to stdio too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:11:56 -04:00
cliffhall
091db9b6dd fix: address Copilot review round 6 on #2318
RemoteClientTransport tracks open streams only when onFetchStreamUpdate
is registered — the bookkeeping serves that callback alone, and without
it the set would grow by one entry per reconnect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:03:34 -04:00
cliffhall
528a37be38 fix: accept a path-less skill root URI in normalizeSkillUri (#2295)
`skill://data-analysis` has an authority and an empty path, which RFC 3986
permits, but the guard required a pathname starting with "/". A server echoing
the skill root back in a directory read was therefore labelled "outside this
skill" instead of "not a direct child". Opaque URIs (`skill:demo`) stay
rejected; a path-less root still names no skill file, so it fails every check
that needs one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:00:49 -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
cliffhall
a38f1322c1 fix: address Copilot review round 5 on #2318
RemoteClientTransport keeps its open-stream bookkeeping whether or not
the consumer logs requests, so onFetchStreamUpdate works on its own and
close() can synthesize the terminal update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 23:40:03 -04:00
cliffhall
abc4967298 fix: address Copilot review round 4 on #2318
- fetchTracking: one non-throwing reporter fronts every stream update —
  the open (inside the fetch wrapper), each coalesced count (from a
  timer) and the close (off the discarded watcher).
- rawWireRequest annotates a request timeout its transport's send
  rejects with, so the browser's relay timeout on a raw-wire call reads
  like every other.
- RemoteClientTransport reports still-open streams closed on close(),
  since the backend watcher's own close cannot reach a channel that the
  disconnect has already torn down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 23:20:28 -04:00
cliffhall
ec09628705 fix: address Copilot review round 3 on #2318
- fetchTracking: the close report is guarded so a throwing listener
  cannot become an unhandled rejection off the discarded watcher; a
  bare CR that is the last byte of the stream terminates its event; a
  moving event count is coalesced to one report per interval (default
  250ms, 0 in tests), with the open and the close still immediate.
- MessageTrackingTransport rolls back a request whose signal was already
  aborted before the send began, and suppresses the rollback only for an
  abort that lands during the send.
- useTickingClock takes an `enabled` flag; Connection Info installs no
  interval when there is no activity section.
- The diagnostics-callbacks test justifies its private-seam cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:53:08 -04:00
cliffhall
1b46b9564b Merge origin/v2/main into v2/feat/2318-timeout-diagnostics
Resolves the relay-wait conflict with #2028: the wait keeps its
progress-driven re-arm and expires with the SDK's timeout shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:48:14 -04:00
cliffhall
c565b56fcd fix: address Copilot review round 2 on #2318
- fetchTracking: media types and HTTP methods compare case-insensitively;
  a `Request` input's own method is honoured; the SSE line scanner
  accepts LF, CRLF and a bare CR, holding a trailing CR until the next
  chunk decides whether it is half of a CRLF.
- MessageTrackingTransport does not report a send failure for a request
  the caller aborted through `requestSignal`, and InspectorClient keeps a
  request whose send failed with a request timeout — both are sent,
  unanswered requests, which is the state to keep reporting.
- createTransportNode builds a fetch tracker when any tracking callback
  is supplied, not only `onFetchRequest`.
- RemoteClientTransport's relay wait expires with the SDK's own timeout
  shape, so the web client's relay timeout is annotated like every other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:27:54 -04:00
cliffhall
1a5dd6820d fix: address Copilot review round 1 on #2318
- fetchTracking: count NDJSON streams line by line instead of leaving
  them at zero forever; justify the detached watcher promise; document
  that a partial SSE block at end-of-stream is dropped, as the SDK's own
  parser (no flush) and the SSE spec drop it.
- uncapturedBodyNote: case-insensitive content-type lookup, and a
  stream label (SSE / NDJSON) for the Network badge.
- InspectorClient: `openedAt` is when the response headers arrived, not
  when the GET went out; the snapshot copies its request records; a
  request the transport failed to send is rolled back out of the
  unanswered set via a new `trackSendFailure` callback; a raw-wire
  timeout is built as the SDK's own timeout shape and annotated the
  same way, so callers see one kind of timeout.
- Remote session storage revives the nested `stream.closedAt` Date.
- Hook test models a legacy double with `Object.defineProperty`, not a
  double cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:03:00 -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
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
d57801d7b3 feat(core): report connection state on a request timeout (#2318)
A request timeout used to surface as a bare `Request timed out`. Every
fact needed to explain it was already in-process: what the client was
still waiting on, when it last heard back, and the state of the
standalone GET notification stream. The SDK's timeout now rejects with
all three, and the same snapshot is shown live in Connection Info and
on the Network entry for the stream.

- core/mcp/connectionDiagnostics.ts: the snapshot type, its one-line
  description, and `annotateRequestTimeout`, which rebuilds the SDK's
  per-request timeout with the state at that moment.
- InspectorClient decorates the SDK Client's `request` (the one funnel
  every verb goes through) so the annotation covers the whole surface;
  tracks outstanding requests with their send time, the last response,
  and the most recent long-lived transport stream; exposes
  `getConnectionDiagnostics()` and `connectionDiagnosticsChange`.
- fetchTracking watches a long-lived stream through a tee'd clone,
  counting SSE events and reporting the close, via a new
  `updateStream` callback wired through the node transport, the remote
  session (`fetch_stream_update`), and the browser transport.
- FetchRequestLogState patches `entry.stream`; the Network entry's
  placeholder reports the event count and open/closed state.
- Connection Info gains a Connection Activity section, ticking while
  open (`useTickingClock`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:25:31 -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
4a1e1ad7a3 refactor: re-export AppRendererClient from types.ts instead of defining it twice (#1745)
Dropping the v1 translation proxy made the protocol's alias identical to the
one core/mcp/types.ts already exported. Keep one definition and re-export it,
so the two cannot drift.

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:49:46 -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
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
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
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