Commit graph inspector/core/mcp
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
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
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
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
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
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
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
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
8dd7bc2920 feat: bound every OAuth-path request with a deadline
Protected-resource-metadata discovery, authorization-server metadata
discovery, dynamic client registration, the token exchange and the
refresh all went out with no AbortSignal at all. Token revocation was
the single exception, and its withDeadline race is the pattern this
generalizes.

Adds core/auth/requestTimeout.ts: withOAuthRequestTimeout wraps a fetch
so every call through it is bounded, composing any caller signal with
AbortSignal.any and racing a timer so the deadline holds even where the
signal is dropped in flight (the browser's remote fetch re-issues the
call to /api/fetch and forwards no signal). The rejection is an
OAuthRequestTimeoutError naming the URL, so a stall says which endpoint
stalled rather than riding the SDK handshake timeout and reporting
"Request timed out".

Applied inside InspectorClient.buildEffectiveAuthFetch rather than to
this.fetchFn, because the transport is handed this.fetchFn directly and
a deadline on a Streamable HTTP / SSE response would sever the stream it
is supposed to hold open. Also applied to the CLI's storedAuthFetch,
which calls SDK discovery directly and so has nothing else bounding it.

Closes #2319

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 00:45:04 -04:00
cliffhall
62fd7d1bd4 fix: address Copilot review round 19 on #2248
Four findings, all real.

- No catalog-level bound. The per-skill caps bound what ONE entry can
  cost; nothing bounded how many entries there are, and SEP-2640 puts no
  ceiling on a catalog — every entry costs at least one resources/read,
  so a large listing made `--verify` run indefinitely and transfer
  unboundedly. Adds SKILL_MAX_CATALOG_SKILLS / SKILL_MAX_CATALOG_BYTES.
  Entries past the budget are still reported, with their static findings
  and an `incomplete` reason, rather than dropped or failed: they were
  not checked, which is neither a pass nor a verdict against the server.

- `responseBytes` charged `text.length`, which counts UTF-16 code units.
  That undercharges non-ASCII by up to 3x, so a decoy block of emoji kept
  the counter under 16 MiB while the wire carried far more. Adds
  `utf8Length`, an allocation-free UTF-8 byte count — deliberately not
  TextEncoder, which would copy a payload the server chose the size of.

- `checkSkillNameCollisions` was O(N^2) in both work and output for a
  group of N: every entry filtered all N URIs and embedded the other
  N-1. Duplicate names are legal and a server controls N. Now names a
  bounded sample and counts the rest.

- `--verify` help documented exit 7 but not exit 8.

The three-tier bounding (per skill, per skill on the wire, per run) is
now a table in clients/cli/README.md, since the run bound is this tool's
limit rather than the spec's and should not read as a conformance rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 23:55:53 -04:00
cliffhall
a3f36ce863 fix: address Copilot review round 18 on #2248
Three findings, all in paths hardened by earlier rounds.

- The 16 MiB read budget was charged only after a content block had
  matched by URI *and* decoded. A server could answer every manifest row
  with one enormous block labelled a different URI (so `contentsFor`
  found nothing) or one enormous invalid-base64 blob (so `skillFileBytes`
  threw), bank zero against the cap, and have the walk issue up to 512
  more. `responseBytes` now charges the raw response before selection or
  decoding; the exact decoded length is substituted when there is one, so
  the common path is still measured precisely.

- The Directory section checked that a child was inside the skill root
  but not that it was a child of the directory actually being read. A
  grandchild, or the directory echoing itself back, passed containment
  and was rendered as navigable. Non-direct entries are now shown and
  labelled rather than linked — `resources/directory/read` answers with
  direct children, so an entry that is not one is itself the finding.

- Containment was decided on the normalized URI while navigation sent and
  stored the raw one. For `skill://r/a/../templates` the first Up produced
  `skill://r/a/..` and a second walked into `skill://r/a`, a directory
  nothing had validated. Directory descent passes the normalized URI, and
  the Up arithmetic moved into `parentOfSkillUri`, documented as valid
  only on a normalized URI.

The suppressed comment (two stderr lines on a failing `--verify`) is
declined again: `--strict` produces the same shape in `emit-result.ts`,
and clients/cli/README.md already documents it as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 23:33:30 -04:00
cliffhall
9117f633aa fix: address Copilot review round 17 on #2248
Four of the five findings share one root cause: the tri-state `outcome`
added in round 15 was not propagated to consumers, which kept branching
on `ok` — true for an `incomplete` report, since nothing that was checked
was wrong.

- CLI `summarizeSkillVerification` counts off `outcome`, not `ok`, so a
  truncated walk no longer prints "no conformance errors" one line before
  exiting SKILL_INCOMPLETE. A mixed failed/incomplete catalog reports
  both counts rather than letting the louder verdict hide the quieter.
- `verifySkills` sets the truncation reason only when entries were
  actually left unread. A file crossing the byte budget as the FINAL
  entry stopped nothing, and "Stopped after 3 of 3" both read as a
  contradiction and demoted a fully-read skill out of `verified`.
- The TUI status line switches on `outcome` through a `Record` over the
  union, so the INCOMPLETE arm is reachable and a fourth outcome would
  be a type error rather than a silently missing label.
- The `incomplete` doc paragraph said it makes `ok` false. It does not,
  deliberately — corrected, and it now names `outcome` as the thing a
  consumer must branch on.
- The web sidebar composes in the per-URI `duplicate-name` warning, so
  two colliding skills are badged in the catalog instead of looking
  clean until one is selected.

The suppressed `selfAttempted` finding is stale — fixed in round 16.

The existing TUI INCOMPLETE test was passing for the wrong reason: its
fixture understated every size, which is itself a mismatch, so the report
was `failed` and only the old `ok`-first branch printed INCOMPLETE. It is
rebuilt with honest digests and sizes, so truncation comes from the
declared-size prefilter and the report is genuinely incomplete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 23:12:28 -04:00