Commit graph

3,887 commits

Author SHA1 Message Date
cliffhall
775107776d fix: address Copilot review round 1 on #2248
Five findings, all real.

**Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed
against the file's advertised digest, so accepting a block the server
labelled something else verifies one file's content against another file's
digest, and can report that as `verified`. A false pass is worse than a
missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity,
which keeps the canonicalized-echo case that motivated the original code
while rejecting an unrelated block, and reports a read error when nothing
answers for the URI. This is what `onReadSkillFile` already did; the two
paths hash bytes against digests and must not disagree about which bytes.

**`skills/get` was dispatched without the extension gate** that `skills/list`
had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring
the extension commits a server to both. Without it, an undeclared server's
-32601 is indistinguishable to a script from the -32602 a declared server
returns for a URI it does not serve.

**The TUI could strand a user on a hidden Skills tab.** The tab left the bar
when the gate went false but `activeTab` did not, so the pane kept rendering
for a server that never declared the extension. Reset to `info`, following
the Auth precedent — additionally gated on `connected`, because this gate
reads a server declaration and would otherwise fire during a reconnect.

**The TUI keyed a verdict by URI**, so a refresh replacing the manifest under
the same URI left hashes computed for the previous snapshot describing the
new one. Keyed on the entry now, as the web screen already was.

**A failed "Load more" discarded the pages on screen and the retry cursor.**
Both are preserved now, and the success and failure paths share one
staleness-guarded `commit` helper so they cannot drift on which results they
may write — which is how they came to disagree.

Two nits: the six-skill counts. The docs sentence was more wrong than
reported (five awkward skills of six, three outright violations) and is
rewritten rather than renumbered.

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 18:43:58 -04:00
cliffhall
9a8488681e feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check
Closes #2248.

Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.

- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
  frontmatter against the entry the listing advertised, field by field. No
  digest can cover this: a digest is taken over the bytes the server served,
  so it proves the file was not altered in transit and says nothing about
  whether the listing described it honestly. Needs a real YAML parser; `yaml`
  was already a root dependency, so this adds no package — but it does newly
  put it on core/'s import graph, hence the three bundler `external` lists.

- `resources/directory/read`: result schemas defined against the normative
  text, `InspectorClient.readResourceDirectory`, a Directory section on the
  Skills screen, and `--method resources/directory/read` in the CLI. The
  client refuses the call locally when the server did not declare
  `directoryRead`, which is the SEP's MUST NOT. A child the directory lists
  but the entry does not is marked `not listed` rather than merged into the
  manifest — the SEP calls a directory result a live observation and forbids
  treating it as extending the manifest.

- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
  `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
  violation. Reads are sequential; a conforming manifest may declare 512
  entries.

- TUI: a Skills pane, shown only when the server declares the extension. Each
  row carries its conformance verdict as a glyph as well as a colour, since
  the pane is read over ssh and through script(1). Enter verifies.

- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
  it per file: it says the session's authorization expired, so absorbing it
  would report N identical read failures and swallow the error the TUI and
  the web commands key off to reauthorize.

Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.

The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).

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 18:10:37 -04:00
cliffhall
16a743f895 fix: give the Ace-worker story a budget that can outlast a loaded machine
`JsonObjectInput > Annotates The Offending Line` waits for Ace's JSON worker to
produce gutter annotations. The worker starts out of process and debounces its
result, so the story already used an explicit `waitFor` — but it asked for
5000ms while the storybook vitest project set no `testTimeout` at all, leaving
it on vitest's 5000ms default. The wait could therefore never win: the test was
killed at exactly the moment its budget expired, and on a loaded machine that is
what happened, reporting `expected 0 to be greater than 0`.

Raise the project's `testTimeout` to 15s and the story's own wait to 10s, so the
wait has room to report what it actually saw. Neither loosens an assertion — a
story that blows 15s has genuinely failed, and a worker that never starts still
fails, just later.

Closes #2292

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtdD6rzc1ddT28ZNE9wUjV
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 18:08:09 -04:00
cliffhall
ff0f5d28cd fix: wait for the reopened SkillsScreen layout to settle before comparing geometry
The tail of the `Long Skill Document` story collapsed and reopened the Skill
Resource accordion and then compared all four section heights against the
pre-collapse sample with an exact `toEqual`. `userEvent.click` resolves once the
event is dispatched, ahead of React committing the `openSections` update,
remounting the panel's content, and the browser running the layout pass that
redistributes height across the flex sections — so the read sampled the panes
mid-redistribution, two heights off by 23px in opposite directions and summing
to the same total.

There is no panel animation involved: the accordion sets `transitionDuration={0}`
because a height animation fights its flex sizing, and App.css restores a
transition only for the chevron transform.

Read the geometry under `waitFor` so it retries until the panes settle. The
reopened layout converges on the pre-collapse geometry rather than passing
through it, so retrying cannot mask a real regression; a tolerance would have
hidden the race instead of removing it, and would still fail on an early enough
sample.

Closes #2278

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtdD6rzc1ddT28ZNE9wUjV
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 17:41:33 -04:00
Cliff Hall
219a08e1b5
Merge pull request #2288 from modelcontextprotocol/v2/fix/2280-insecure-token-endpoint-notice
fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
2026-09-07 16:34:41 -04:00
Cliff Hall
a16df92f8d
Merge branch 'v2/main' into v2/fix/2280-insecure-token-endpoint-notice 2026-09-07 15:51:22 -04:00
cliffhall
a5a9d4fe15 fix: the classifier does not survive structuredClone, and the test now proves it
Since round 1 the doc claimed the `name` fallback covers "a structured clone or
a JSON hop". Verified, and it is false for half of that:

  structuredClone(new InsecureTokenEndpointError(url))
    -> name: "Error", tokenEndpoint: undefined, constructor: Error

structuredClone normalizes a custom Error subclass back to Error, so neither
arm has anything left to match. A caller routing this error through
structuredClone or postMessage would silently get the generic retryable
handling back — the exact defect this PR removes.

The test could not have caught it: it hand-built a look-alike plain object, so
it asserted what I believed the boundary does rather than what it does. It now
performs a real JSON round trip (asserting the prototype is gone), and a second
case pins that structuredClone is NOT recognized — turning a false claim into a
tested limitation.

The doc comment now scopes the contract to JSON and states the structuredClone
limit with the remedy: serialize the fields explicitly across such a boundary.

Also removes an orphaned JSDoc block the round-4 refactor stranded above the
report helper, and gives showInsecureTokenEndpointNotice its own doc back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 11:16:51 -04:00
cliffhall
c2d566ad8d refactor: one shared reporter for the terminal refusal (review round 4)
In round 2 I claimed a single wrapper "stops a future path omitting the banner
clear". That was only true inside useOAuthRecovery. useConnectionLifecycle
hand-wrote the notice-plus-scoped-clear pair three times, so the exact failure
mode I said was impossible was one careless edit away — in the PR that claimed
it.

reportTerminalInsecureTokenEndpoint now lives in lib/ and both hooks route
through it; zero hand-written pairs remain. It is generic over the banner shape
rather than importing ReAuthBannerState, since useOAuthRecovery already imports
this module and naming its type would close a cycle. All it needs is a serverId
to compare.

The helper's header also still said "the three OAuth failure paths". There are
seven. Rather than update the count I removed the enumeration and recorded why:
a list of callers is a comment that rots on the next round, which is what just
happened to it.

Also fixes "a /etc/hosts" -> "an /etc/hosts".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 10:59:56 -04:00
cliffhall
8ca00fa2d6 test: make the not-an-OAuthError test assert its own title (review round 3)
The test was named "is not an OAuthError, which is why the retry path must not
claim it" and asserted only `name` and `typeof tokenEndpoint`. Neither touches
the OAuth hierarchy, so a future SDK reparenting the class would leave it
passing with its title now false — and the comment promising the #2280 handling
would be revisited if that changed was hollow. A test that has quietly stopped
testing its subject is worse than no test.

It now asserts against the hierarchy both ways, `OAuthError.isInstance(err)`
and `instanceof`. Verified by mutation rather than assumed: flipping the
assertion to simulate the reparenting fails exactly this test and nothing else
(1 failed, 15 passed).

Also fixes a doubled "terminal terminal", collateral from the round-2 SEP label
removal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 10:45:26 -04:00
Cliff Hall
a5b356d0e2
Merge pull request #2290 from modelcontextprotocol/v2/docs/2289-allowed-origins-localhost
docs: ALLOWED_ORIGINS recipe for a `*.localhost` proxy
2026-09-07 10:39:08 -04:00
cliffhall
0acac24cda docs: every state-changing request 403s, not just the POSTs (review round 1)
The troubleshooting note said only the POSTs that add or connect a server are
rejected. Verified otherwise: core/react/useServers.ts sends PUT for saving and
reordering and DELETE for removing, the origin middleware is method-agnostic,
and browsers attach Origin to anything that is not a GET/HEAD. So settings
saves, reorders and deletes fail identically.

That matters for a troubleshooting doc — someone whose *save* silently fails
would not have recognised themselves in the old wording. The POSTs stay as the
most visible examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 10:00:42 -04:00
cliffhall
084a083e2d fix: usable IPv6 spelling, and drop a SEP label that means something else here (round 2)
- The recovery text told users to move the endpoint to `::1`. A bare IPv6
  literal is not a legal URL host — `new URL("http://::1/token")` throws — so
  anyone copying that into the Token URL override got a parse error from advice
  meant to unblock them. Now `[::1]`, in both the notice and the manual
  reproduction guide.

  The exemption list still reads `::1`, because that is the host the SDK
  compares; only the remedy is bracketed, because that is what a user types.
  The comment says so, so neither gets "corrected" into the other.

- Dropped SEP-2207 from all nine files it had reached. I took the label from
  the SDK's own source comments, which attribute this check to it — but this
  repo already uses SEP-2207 for OIDC refresh / offline_access
  (specification/v2_auth_hardening.md, plus an e2e test), so grepping it here
  would have turned up two unrelated things. Replaced with plain description
  rather than a different SEP number: having guessed wrong once, guessing again
  is not the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:54:17 -04:00
cliffhall
6650c0b525 docs: ALLOWED_ORIGINS recipe for a *.localhost proxy
Closes #2289

Running the Inspector behind a reverse proxy on a *.localhost name works
today, but only with ALLOWED_ORIGINS set, and nothing said so or showed the
shape. Raised by the reporter of #1944, whose whole service cluster uses those
names.

Three things the recipe has to carry, all learned the expensive way:

- ALLOWED_ORIGINS REPLACES the default list rather than merging, so an entry
  of only the proxy origin silently breaks browsing at localhost:PORT. The
  loopback trio has to be listed too.
- The failure is confusing rather than obvious. A same-origin GET carries no
  Origin header, so the page loads and only the POSTs that add or connect a
  server are rejected — which reads as a connection problem rather than a
  configuration one.
- Only the browser resolves *.localhost for free. Chrome and Firefox map those
  names to loopback per RFC 6761; the OS resolver on macOS does not and Safari
  does not at all, so an MCP *server* on such a host still needs /etc/hosts or
  dnsmasq — the Node backend is what dials it — and with OAuth is refused by
  the SDK regardless (typescript-sdk#2591).

Documented rather than changing the default: see #2282 for why ~1,100 lines of
origin and CSP code was a bad trade for removing one env var.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:44:33 -04:00
cliffhall
f12ce5aaf8 fix: correct two false claims in the terminal-notice copy (review round 1)
Both are accuracy defects in the one message whose entire job is to be
actionable, so both matter more than their size.

- "before any credentials were sent" is FALSE on the mid-session refresh and
  re-authentication paths this same notice serves, where credentials were
  legitimately sent earlier in the session. A user connected for an hour would
  read it as describing some other failure. Now scoped to the request actually
  refused: "without sending this request".

- The recovery guidance named "Server Settings → Authorization". The UI renders
  that accordion as "OAuth Settings" and the field as "Token URL override"
  (ServerSettingsForm.tsx), so the message directed people to a section that
  does not exist, precisely when it was asking them to go reconfigure
  something.

Both pinned by tests, including negative assertions on the old wording so it
cannot drift back.

The round's third finding was against test-server-http.ts's strictPort
precondition, which no longer exists — that option was dropped in 6c1b532,
after the review ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:38:02 -04:00
cliffhall
6c1b5325f0 test: drop strictPort and document the port collision instead
strictPort was a new test-server config option — plus its validation and 18
tests — invented solely to keep one fixture's hard-coded issuer port honest.
That is infrastructure built for a screenshot, so it goes.

test-servers/src is now byte-identical to v2/main again. The fixture keeps its
fixed port and docs/test-servers.md carries the caveat instead, written to be
actionable rather than merely cautionary: the failure mode is confusing rather
than obvious, since a relocated server leaves discovery pointing at whatever
unrelated process holds 8091, so the note says to check the port before
believing what the flow does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:23:02 -04:00
cliffhall
b4b82493b1 fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
Closes #2280

InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.

Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.

Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.

Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.

Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.

Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:09:54 -04:00
Cliff Hall
98911df74d
Merge pull request #2287 from modelcontextprotocol/v2/fix/2242-cimd-registration-kind
fix: preserve CIMD registration provenance across SDK issuer binding
2026-09-07 08:11:36 -04:00
cliffhall
aca522de6a fix: delegate issuer context and discovery state through the EMA wrapper
Copilot review (#2287): `EmaTransportOAuthProvider` forwarded
`clientInformation` / `saveClientInformation` without the SDK's `ctx`, and
implemented neither `discoveryState` nor `saveDiscoveryState`. Since the wrapper
does expose `clientMetadataUrl`, an EMA connection can still take SDK `auth()`'s
URL-based-client-ID branch — and the inner provider then saw
`issuer === undefined` with no discovery state to read back, so the CIMD write
was recorded as DCR.

Forward `ctx` on both, and delegate the two discovery-state methods to the inner
provider. Both were pre-existing SEP-2352 gaps in their own right: dropping the
issuer put every EMA read and write on the unkeyed slot, and the missing
discovery state meant the SDK re-discovered on every call and warned that it
could not run its callback-leg authorization-server binding check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:38:13 -04:00
cliffhall
be08f6d37d test: restore end-to-end coverage of resolveSdkRegistrationKind
Copilot review (#2287): the CIMD E2E assertion had gone vacuous. Once the
pre-registration started writing the issuer-keyed slot itself, the SDK found an
already-stamped credential and never called `saveClientInformation` at all — so
the assertion held even with the resolver gutted. Verified: reverting the
dispatch to `: "dcr"` left all 34 tests passing.

Add a case that seeds the legacy *unkeyed* CIMD registration — what every
pre-SEP-2352 install has on disk, and the exact shape #2242 was reported
against. The SDK back-stamps it with the issuer, which is the save that used to
relabel it `Dynamic (DCR)`, so the resolver is genuinely on the path.

That case now fails on both SSE and Streamable HTTP when the dispatch is
reverted, which is the reported bug reproduced across the integration boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:28:07 -04:00
cliffhall
6273790077 fix: read the SDK's own discovery state instead of a marker of our own
Copilot review (#2287), two findings — and taken together they said the marker
was the wrong mechanism, so this removes it rather than patching it again:

- A transient failure in our CIMD preflight recorded no marker, so a *successful*
  SDK fallback discovery that advertised CIMD was still stored as `dcr`.
- `existing` and `existingKind` could describe different registrations, because
  `clientInformation()` answers preregistered-first while `clientRegistrationKind()`
  prefers the issuer's dynamic slot.

SDK v2 `auth()` persists the authorization-server metadata via
`saveDiscoveryState` *before* it reads or writes client information, and it takes
its URL-based-client-ID branch — rather than `registerClient` — exactly when that
metadata advertises `client_id_metadata_document_supported` and a
`clientMetadataUrl` is configured. So the branch is not inferred at all now: it is
read back from the state the SDK itself just wrote.

`resolveSdkRegistrationKind` becomes two cases. A save for an issuer that already
has a registration under this `client_id` is a back-stamp, answered by that
registration's recorded kind — so an existing DCR on the metadata URL stays
`dcr`. A save for an issuer with none is a new registration, answered by the
discovery state, guarded on the metadata describing that same issuer.

This drops `cimdClientMetadataUrl` and its storage accessors entirely, and
restores `ensureCimdClientRegistration` to a plain issuer-bound pre-registration.
Invalid-client recovery, second-issuer CIMD, second-issuer DCR minting the
metadata URL, and preflight failure are all consequences of the two cases rather
than separate mechanisms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:03:12 -04:00
cliffhall
abb6a47f7d fix: earn the CIMD marker, rather than writing it on AS support alone
Copilot review (#2287): the marker was written as soon as the AS advertised
`client_id_metadata_document_supported`, before checking what registration
already existed. RFC 7591 §3.2 leaves a dynamically issued `client_id` opaque,
so an existing DCR may carry the configured metadata URL — and marking the
issuer then relabels that real dynamic registration as CIMD on the SDK's next
issuer stamp.

The marker now records both facts it is read for: that this AS accepts the URL
as a `client_id`, *and* that the registration standing for it got there through
CIMD. It is written where this helper establishes the registration itself,
reaffirmed for one already recorded as `cimd` under that exact URL, and withdrawn
otherwise — which also covers a static client and an AS that has stopped
advertising CIMD.

Covered at both levels: a mocked case asserting the marker is withdrawn for an
existing DCR on the same URL, and an end-to-end real-storage case asserting such
a registration is still `dcr` after the SDK issuer-stamps it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:46:59 -04:00
cliffhall
4350731f36 docs: correct the saveClientInformation contract comment
Copilot review (#2287, suppressed comment): the comment still said internal
callers supply no issuer, which stopped being true when
ensureCimdClientRegistration started binding its save to the discovered issuer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:35:03 -04:00
cliffhall
4297ef8449 fix: record CIMD provenance as an issuer-keyed marker, not on the credential
Copilot review (#2287), three findings:

1. **invalid-client recovery lost the provenance.** SDK v2 `auth()` answers
   `invalid_client` / `unauthorized_client` with `invalidateCredentials("client")`
   and an immediate retry; our clear drops the registration *and* its kind, so
   the retry's URL-based client-ID save landed with nothing recorded and was
   stored as `dcr`. Provenance now lives in `cimdClientMetadataUrl`, an
   issuer-keyed marker on the issuer slot that records a property of the AS
   rather than a credential — `clearClientInformation` deliberately leaves it
   alone. `ensureCimdClientRegistration` writes it, and withdraws it when the AS
   stops advertising CIMD, on every connect.

2. **Discovery became a hard network dependency.** Moving the existing-client
   check after discovery meant a well-known outage could fail a reconnect the SDK
   would have served from its persisted discovery state. Reuse
   `provider.discoveryState()` first, and treat a discovery failure as "skip
   pre-registration" rather than an error — this helper is an optimization over
   what `auth()` does for itself.

3. **Dropped an unjustified double cast** in the test fetch helper; the async
   signature is directly assignable to `typeof fetch`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:34:03 -04:00
cliffhall
4c6a9fb376 fix: bind CIMD provenance to the issuer it was discovered for
Copilot review (#2287): the active-issuer fallback added last round could copy
issuer A's CIMD provenance onto a real dynamic registration for issuer B, since
RFC 7591 §3.2 permits B to mint the same opaque URL as its `client_id`.

Fix it at the source rather than in the resolver. `ensureCimdClientRegistration`
now runs discovery first and records the registration against the issuer it just
discovered, having confirmed *that* AS advertises
`client_id_metadata_document_supported`. Its "already registered?" check moves
after discovery and is keyed by that issuer — read ctx-less it resolved through
the active issuer and early-returned for every later one, which is what forced
the cross-issuer fallback in the first place.

`resolveSdkRegistrationKind` is therefore issuer-scoped again, with no fallback.
A second AS behind one resource now gets its own determination: CIMD when it
advertises CIMD, `dcr` otherwise — including when it mints the metadata URL as
its own `client_id`.

The cost is a discovery round trip per connect attempt rather than only the
first; noted at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:16:30 -04:00
cliffhall
f3f68c731b fix: keep CIMD provenance when a resource resolves to a second issuer
Copilot review (#2287): scoping the stored-CIMD check to the incoming issuer
alone mislabels the SDK's own CIMD write for a second authorization server.
SEP-2352 keys registrations per AS, so the first binding promotes the unkeyed
CIMD entry into issuer A's slot and clears the fallback;
`ensureCimdClientRegistration` then early-returns on its ctx-less read, and the
save under issuer B finds nothing recorded for B.

Check the issuer slot and then the server's active registration, so a second
issuer stays CIMD while a `client_id` the AS minted itself still falls through
to `dcr`. Covered by two tests driving a real `OAuthStorageBase` through the
A → B sequence, since the behaviour under test is how storage promotes and
clears slots rather than anything a mock would express.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:44:49 -04:00
cliffhall
c8c050b51e fix: require a recorded CIMD registration before preserving the kind
Copilot review (#2287): RFC 7591 §3.2 leaves a dynamically issued `client_id`
opaque, so `client_id === clientMetadataUrl` is not on its own proof that CIMD
ran — an authorization server could in principle mint that value from
`POST /register`.

Narrow the claim to a conjunction, whose decisive term is a registration we
recorded as CIMD ourselves rather than an inference about what the AS returned:
CIMD must be configured for this connection, the incoming `client_id` must be
exactly that metadata-document URL, and the registration already stored under
that id must be recorded as `cimd`. Everything else falls through to `dcr`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:35:43 -04:00
Cliff Hall
2c25259dd2
Merge branch 'v2/main' into v2/fix/2242-cimd-registration-kind 2026-09-07 00:28:22 -04:00
Cliff Hall
b785bf3e69
Merge pull request #2285 from modelcontextprotocol/v2/feat/2205-toggle-schema-warnings
feat(web): collapse the schema-portability section by default
2026-09-07 00:16:02 -04:00
cliffhall
4789cb954e fix: preserve CIMD registration provenance across SDK issuer binding (#2242)
`BaseOAuthClientProvider.saveClientInformation` treated every save without an
explicit `registrationKind` as DCR. The SDK reaches it from three places in
`auth()` and only one is a dynamic registration, so the issuer-binding write
overwrote the `cimd` provenance our own pre-registration had just stored — and
Connection Info reported `Dynamic (DCR)` for a connection that never issued a
`POST /oauth/register`.

Recover the kind instead: a `client_id` equal to the configured
`clientMetadataUrl` is CIMD (which also covers the SDK's own CIMD write), and
otherwise a stored registration with the same `client_id` carries its recorded
kind forward. Matching on `client_id` keeps stale CIMD provenance from leaking
onto a later DCR registration for the same server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:04:40 -04:00
cliffhall
db122f53db feat(web): collapse the schema-portability section by default (#2205)
The Tools tab rendered every schema-portability finding, always open, above
the argument form. On a server whose schemas are broadly unportable that put
the same wall of text ahead of every tool's first input — and the findings
address the server author, not the caller trying to fill the form.

The section now opens collapsed behind the same disclosure heading the Skills
pane's Conformance section uses, with an `N error(s), M warning(s)` badge. The
expand choice is global rather than per tool (`useSchemaFindingsExpanded`,
stored as `inspector.schemaFindings.expanded`): this panel is reused across
selections, so a per-tool disclosure would re-collapse on every click and
reproduce the same scrolling. The badge stays visible while collapsed and the
tool-list severity icon is untouched, so nothing about a tool's standing is
hidden by the closed state.

Two neighbouring fixes on the same panel:

- `JsonEditor` frames Ace in a bordered `Paper`. Ace paints its background edge
  to edge with nothing around it, so "Edit as JSON" replaced a column of
  bordered inputs with one borderless slab.
- `ToolDetailPanel`'s body `ScrollArea` overrode the app-wide `type="scroll"`
  default with `type="auto"`, parking a permanent bar down the side of any tool
  whose form is taller than the panel. `offsetScrollbars` stays, so the form
  does not shift sideways when the bar fades in.

`test-servers/configs/unportable-schemas-many-http.json` reproduces the
original complaint: the same four presets as `unportable-schemas-http.json`
carrying 26 findings rather than 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU3QYnc95g6EM4vo3hYNet
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 23:52:34 -04:00
Cliff Hall
aab6475527
Merge pull request #2283 from modelcontextprotocol/v2/feat/2201-seed-example-server
feat: seed the MCP org example server as a remote first-run card
2026-09-06 23:35:12 -04:00
cliffhall
d476dc5085 docs: the catalog is seeded on first read, not at boot
Copilot review round 2. The First-run behavior section said the backend
writes the seed file "when the backend boots". It does not: the write lives
in the `GET /api/servers` handler
(`core/mcp/remote/node/server.ts`), so a backend that boots and never
serves the list leaves the path untouched.

Verified against a prod build with an isolated `MCP_CATALOG_PATH`:

  after boot + GET /       : catalog ABSENT
  GET /api/servers         : 200
  after GET /api/servers   : catalog EXISTS

The section now says so, and picks up two adjacent facts the old wording
also left out: the write happens inside the write lock (so a concurrent
POST/PUT/DELETE cannot be clobbered), and a read-only `--config` source is
never seeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012x3rC3JXU17aW3uSwjPoi5
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 23:17:32 -04:00
cliffhall
8fd9e41913 docs: refresh the server-list spec's seed contract for the third seed
Copilot review round 1. `specification/` is a maintained design record, not a
historical one, so a shipped change to the seed set belongs in it.

Two statements were stale and are now accurate:

- **First-run behavior** said the backend writes "the two current
  `SEED_SERVERS`" — a reference to the `App.tsx` constant this design
  replaced. It now names `DEFAULT_SEED_CONFIG`, records that the set is three
  as of #2201, and points at `core/mcp/serverList.ts` as the source of truth
  for the contents rather than restating them; a second copy of a list is what
  goes stale.
- The API sketch's `// the two existing seeds` comment is likewise corrected.
  Its `export function DEFAULT_SEED_CONFIG` is fixed to `export const` on the
  same line — it is a `const`, and leaving a known-wrong signature on a line
  being edited for accuracy is worse than the one-word fix.

The On-disk format block is deliberately unchanged: it illustrates the file
*shape*, not the seed set, and already carries a `streamable-http` entry
(`acme-api`) alongside the two stdio ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012x3rC3JXU17aW3uSwjPoi5
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 23:13:04 -04:00
cliffhall
3e2546b040 feat: seed the MCP org example server as a remote first-run card (#2201)
Adds a third entry to `DEFAULT_SEED_CONFIG`, `example-server-default`, a
Streamable HTTP card pointing at the MCP org's own feature-reference server
(https://example-server.modelcontextprotocol.io/mcp). Until now every seeded
server was a local stdio process and every remote example in the docs was a
placeholder, so trying a remote connection meant finding or standing up a
server first.

The endpoint is protected by OAuth, but its authorization server advertises
Dynamic Client Registration and fronts a *mock* upstream IdP, so the whole
round trip completes with no account, no API key and no pre-registered client
— which is what makes it viable as a shipped default. Verified end to end
against a prod build: the card connects, negotiates MCP 2025-11-25 and lists
the reference server's tools.

The seed carries no `protocolEra`. That is not a default-by-omission: the
server answers the plain `initialize` handshake and rejects `server/discover`,
so it has no modern (SEP-2663) era to negotiate — `"auto"` would only buy a
failed probe and `"modern"` would fail the connection outright.

Docs updated alongside: the seeded-catalog block and per-client table in
`docs/mcp-server-configuration.md`, the Streamable HTTP example there (which
now leads with the live endpoint and keeps a placeholder to illustrate
`headers`), and the seed description in `clients/launcher/README.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012x3rC3JXU17aW3uSwjPoi5
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 22:57:53 -04:00
Cliff Hall
849eb08db0
Merge pull request #2281 from modelcontextprotocol/v2/fix/2206-duplicate-resource-uri-keys
fix: key resource list rows by position so duplicate URIs cannot collide
2026-09-06 22:29:38 -04:00
Cliff Hall
750aa5bef7
Merge pull request #2279 from modelcontextprotocol/v2/fix/2207-tui-force-color-ansi
fix: strip ANSI from TUI test frames so FORCE_COLOR cannot break assertions
2026-09-06 20:38:16 -04:00
cliffhall
339c5f4e27 fix: duplicate any listed resource URI, not only a registered one
Copilot on #2281: the option's contract said an unregistered URI is ignored,
but `withDuplicateResources` runs on the assembled list, which also carries
whatever a resource template's `list` callback contributed — so a
template-listed URI was duplicated in contradiction of its own doc.

Broadened rather than restricted, because scoping it to `registeredResources`
would leave half the surface unreproducible: both kinds of entry land in the
same Resources sidebar list and collide on the same React key. The contract now
says so on `ServerConfig.duplicateResourceUris` and on the config-file mirror,
and an integration case covers a template-listed URI — it fails if the override
is narrowed to `state.registeredResources`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 20:15:20 -04:00
Cliff Hall
1b5a0b5b3c
Merge branch 'v2/main' into v2/fix/2207-tui-force-color-ansi 2026-09-06 20:04:33 -04:00
Cliff Hall
eab42d393d
Merge pull request #2276 from modelcontextprotocol/v2/fix/2224-union-required-undeclared
fix: decline a union branch requiring a name it never declares
2026-09-06 20:00:53 -04:00
cliffhall
a3bb679f1e fix: strip ANSI from TUI test frames so FORCE_COLOR cannot break assertions (#2207)
Ink writes styling as escape sequences inside the styled run, so an
accelerator underline splits the word it decorates: `<Text underline>I</Text>nfo`
reaches the frame buffer with escapes between `I` and `nfo`, and a plain
`toContain("Info")` fails against a component that is rendering correctly.
Chalk only emits color when it detects a TTY, so CI stays green and the six
failures land only on a developer whose shell exports FORCE_COLOR.

Add `__tests__/helpers/renderTui.tsx` — ink-testing-library's `render` with
`lastFrame()` and `frames` ANSI-stripped — and point all 16 renderer test
files at it. `frames` is a getter reading through to the live array, since
Ink appends to it as the component re-renders; the raw bytes stay reachable
as `stdout.lastFrame()`.

Pinning FORCE_COLOR=0 would turn the six green while hiding the class: any
future assertion on styled text has the same problem, and the suite would
stop covering the styled path at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFp2LVH7UJST5i8bDBJghw
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 20:00:15 -04:00
cliffhall
3153f0305a fix: key resource list rows by position so duplicate URIs cannot collide
The Resources sidebar keyed every row on its `uri` (and templates on
`uriTemplate`), but nothing in MCP makes those unique — a server may return
the same URI twice, and `ManagedListState.applyItems` replaces the list
wholesale, so a repeat is what the server actually sent. React logged
`Encountered two children with the same key` on every render, and a
filtered-out row survived reconciliation instead of unmounting.

This is the resources counterpart of #1957. The row identity is now the same
`listRowKey(id, sourceIndex)` the Tools sidebar has used since then, extracted
so the two formats cannot drift; `toolRowKey` delegates to it. Applied to all
three Resources lists and to `RootsTable`, whose user-maintained list can
collect a duplicate URI the same way.

Selection stays keyed on the URI, deliberately: unlike a duplicated tool name
(#2001), duplicate rows here denote the same resource — a `resources/read`
takes the URI — so highlighting both is correct. Only the React key needs to
tell them apart.

Adds `ServerConfig.duplicateResourceUris` and the
`duplicate-resource-uris-http.json` showcase config, the `resources/list`
mirror of `duplicateToolNames`, so the defect has a fixture to reproduce it —
appended rather than adjacent, for the reason #1957 documented.

Closes #2206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 19:43:47 -04:00
Cliff Hall
665e8aa84a
Merge branch 'v2/main' into v2/fix/2224-union-required-undeclared 2026-09-06 17:57:52 -04:00
Cliff Hall
42babb7482
Merge pull request #2277 from modelcontextprotocol/v2/fix/2220-empty-string-cursor
fix: send an empty-string pagination cursor verbatim on every list adapter
2026-09-06 17:55:42 -04:00
Cliff Hall
21d58c04c3
Merge branch 'v2/main' into v2/fix/2220-empty-string-cursor 2026-09-06 17:55:18 -04:00
Cliff Hall
7f4d851655
Merge branch 'v2/main' into v2/fix/2224-union-required-undeclared 2026-09-06 17:48:15 -04:00
Cliff Hall
8bca482111
Merge pull request #2275 from modelcontextprotocol/v2/docs/1886-cli-smoke-testing-guide
docs: add a reusable CLI smoke-testing guide for v2
2026-09-06 17:47:50 -04:00
cliffhall
0a30935f4b test: assert a fully-declined union still counts as taking arguments
`declaresAnyFields` decides whether a tool is auto-invoked with `{}` or asked
about, and it counts a required name a schema never declares — so the #2224
check making such a union render nothing must not be read as "no arguments".
Nothing changed here; the invariant just had no test of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYT4eKdnx1yRw7x4bW7Ts
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 15:35:14 -04:00
cliffhall
8dfb2c8c4b docs: harden the Actions example in the CLI smoke-testing guide
Copilot round 7. The copyable job inherited the repository's default
GITHUB_TOKEN scopes and left that token in the runner's git config while
`npx` executed a package downloaded from npm.

Adds `permissions: contents: read` and `persist-credentials: false`,
each with a comment giving its reason.

The review cited `.github/workflows/main.yml:11-18` as precedent for
both. It supports one: main.yml does set `permissions: contents: read`
at workflow level, for exactly the stated reason, and every workflow in
this repo carries a block. It does not support the other —
`persist-credentials` appears nowhere in .github/workflows. That half is
taken on its own merit rather than as convention: the job uses git for
nothing after checkout, so the credential is only attack surface while
third-party code runs.

Both matter more in this example than in a workflow we own, because it
is written to be copied into a repository whose defaults the reader
cannot see — which is now stated in the guide.

The edited block was parsed as YAML to confirm both keys land where
intended rather than eyeballing the indentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsKC3HniVF9dCZtDVbYp5B
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 14:41:56 -04:00
cliffhall
e20559ce84 docs: correct app-info signaling and the pipefail claim (Copilot round 6)
Two findings, both real, both mine — and both found by a review round
requested after an explicit "approval recommended", which is the
argument for not stopping at one quiet round.

1. The guide said the `hasApp:false` case is reported "through exit code
   2 rather than through a key". It is reported BOTH ways:
   `{"appInfo":{"hasApp":false,"toolName":"echo"}}` with exit 2. This
   was the round-3 fix over-correcting — that round established there is
   no `result` key under --app-info, and I generalized it into "no key
   reports this", which does not follow. Now documents both signals and
   when each is the right one to branch on: the exit code short-circuits
   an && chain without parsing, while the field is the only one of the
   two that survives `tools/list --app-info`, whose NDJSON reports every
   tool over one connection and exits 0 regardless.

2. §8 claimed "the CLI's own exit code propagates through set -e". It
   does not. `pipefail` reports the RIGHTMOST non-zero status, so a
   tools/call returning isError:true — which exits 5 and also falsifies
   the assertion — surfaces as 1, and the §5 `case` block the guide
   pointed at would print the catch-all instead of naming the tool
   error. Verified: left=5/right=1 → 1; left=5/right=0 → 5; and the real
   CLI piped into `jq -e '.result.isError != true'` → 1.

   Added the status table and corrected §8 to say the job carries
   pass/fail but not the class, with the capture-then-assert form as the
   way to get it. Verified that form recovers exit 5 where the pipeline
   reported 1, and that the happy path still passes.

Worth recording: the first attempt to reproduce finding 2 was itself
wrong — feeding jq invalid JSON made it exit on a parse error and the
pipeline returned 5, appearing to refute the claim. The controlled test
and the real CLI both confirm it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsKC3HniVF9dCZtDVbYp5B
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 14:30:18 -04:00
cliffhall
ed83bfba3b docs: reconcile store isolation with --stored-auth-only in the §8 script
Copilot round 4. The complete script created an empty isolated OAuth
store and then always passed --stored-auth-only, so against an
OAuth-protected server it could never authenticate — exit 3 by
construction — while being presented as the general "arbitrary MCP
server" template.

The two requirements are genuinely in tension: §6 wants isolation so a
run cannot read or rotate real tokens, and --stored-auth-only wants a
token to reuse. Round 2 made the isolation airtight without reconciling
that, which is what turned a latent gap into a guaranteed one.

Resolved by making credential provisioning explicit rather than by
scoping the script down to unauthenticated servers: it now takes a
bearer token from the CI secret store when MCP_TOKEN is set — the
approach §6 already recommends for unattended runs — and is unchanged
when it is not. Added a callout naming the tension, wired MCP_TOKEN
through the Actions job, and noted that a grant requiring genuine
interactive OAuth does not belong in a CI smoke test at all.

The conditional argument uses "${auth[@]+"${auth[@]}"}" because an empty
array is an unbound variable under `set -u` in bash before 4.4, and
macOS still ships 3.2 (verified locally). Documented why, so it is not
simplified into ${MCP_TOKEN:+--header "..."} — that form happens to
preserve its inner quoting in bash, but as a shell-specific subtlety a
reader cannot check rather than something the script states.

Verified against a live HTTP test server both ways: MCP_TOKEN unset and
MCP_TOKEN set to a value containing a space both pass all three
assertions. Argv construction checked directly — the header arrives as
one word with the token intact, and no empty argument is passed when the
variable is unset.

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