Commit graph

3,437 commits

Author SHA1 Message Date
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
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
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
cliffhall
4886ecd520 docs: address Copilot review round 3 on the CLI smoke-testing guide
Two findings, both real; the first was introduced by the round-2 fix.

1. The envelope table said `result` is present "Always". It is not:
   `emitResult` returns early for `--app-info` and writes `{appInfo}`
   alone, with no `result` key — in the `hasApp:false` case too, which
   is signalled through exit code 2 rather than through a key. So a
   consumer that requires `.result` breaks on every probe. Documented
   as a distinct shape rather than a footnote on the table, since that
   is what it is. Verified against the mcp-app-http fixture: keys are
   ["appInfo"] with --app-info for a tool with and without an app, and
   ["appInfo","result"] for the same tool called without it.

   Worth noting this was self-inflicted — the table arrived in the
   round-2 fix for `schemaFindings`, and stating a shape more precisely
   is exactly where an over-broad "always" slips in.

2. The Actions job ran `./smoke.sh` while the guide never tells anyone
   to chmod +x, and a file copied out of a code block does not carry the
   mode bit. Now `bash smoke.sh`, with a comment saying why so it is not
   tidied back. Verified: ./nonexec.sh fails with "permission denied",
   bash nonexec.sh runs.

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:03:26 -04:00
cliffhall
3b4eb447c9 fix: walk past an empty-string cursor in the legacy task refresh (#2220)
`ManagedRequestorTasksState.refresh()` drives the legacy `tasks/list` walk
itself, and both of its cursor tests were truthiness checks — so the fixed
adapter was never reached. `while (cursor)` ended the walk on a `""`
`nextCursor`, leaving the user-visible task list on page one, and
`cursor ? [...this.tasks, ...page] : page` would have replaced the
accumulated list rather than extended it had the walk continued.

Both now test `=== undefined`. The other four paged/managed stores already
did (`pagedToolsState`, `pagedPromptsState`, `pagedResourcesState`,
`pagedResourceTemplatesState`, `pagedRequestorTasksState`,
`managedSkillsState`); this was the last walk on the truthiness side.

Covered by a new case in `managedRequestorTasksState.test.ts` that paginates
`"" → "c2" → end` and asserts the cursor each call received, so a walk that
terminated correctly but re-requested page one still fails. Reverting either
guard on its own fails exactly that case.

Reported by Copilot on #2277.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:59:51 -04:00
cliffhall
d4f2f2b127 docs: address Copilot review round 2 on the CLI smoke-testing guide
Four distinct issues across 2 inline and 3 suppressed comments. All were
verified against the CLI's own code and behavior before being accepted;
one was diagnosed correctly but explained wrongly, and is documented as
it actually behaves rather than as the comment stated.

1. MCP_STORAGE_DIR alone is not isolation. MCP_INSPECTOR_OAUTH_STATE_PATH
   is checked first, so an inherited value silently wins and a smoke run
   still reaches the real token file — the guide documented that
   precedence and then ignored it one paragraph later. Both variables are
   now exported, in precedence order, in §6 and in the §8 script, with a
   --list-stored-auth check that prints the path actually resolved.
   Verified: with a poisoned MCP_INSPECTOR_OAUTH_STATE_PATH in the
   environment, the two-line export wins.

2. `--format json` can carry a third top-level key. `emitResult` sets
   `schemaFindings` whenever --strict is passed to tools/list and there
   is at least one finding, so "that is the whole contract" was wrong.
   Replaced with a table of all three keys and a note to parse by key.

3. --strict findings under --format json. The actionable half is right —
   a JSON pipeline should read `schemaFindings` from stdout. The stated
   mechanism is not: `writeSchemaLintReport` runs unconditionally, so
   --format json --strict emits the full human report on stderr AND the
   structured copy on stdout. Documented both streams as measured, with
   a jq gate over the per-tool grouping. The `?` operators in that filter
   are load-bearing: the key is absent on a clean run, and without them
   jq errors instead of passing. Both arms verified.

4. The ErrorEnvelope snippet returned 0. `cmd || { … }` makes the brace
   group's status the list's, so a successful jq turned a failed run
   green — the same failure class the guide warns about twice elsewhere,
   which makes shipping it worse. Now captures, reports, and exits with
   the status; verified propagating exit 4.

The §8 script was re-run end to end against a live HTTP test server with
the added export and still passes.

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 13:43:41 -04:00
cliffhall
9b35e530fc fix: send an empty-string pagination cursor verbatim on every list adapter (#2220)
An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.

`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.

`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.

Coverage:

- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
  five adapters — `""` carried, an absent cursor sending no `cursor` key,
  a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
  out `""` as the cursor for page two, the shape no other fixture produces,
  and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:28:44 -04:00
cliffhall
c80f90ed09 docs: address Copilot review round 1 on the CLI smoke-testing guide
Three findings, all real.

1. The secret-scan snippet swallowed the CLI's exit code. `out=$(…)`
   with no status capture means a non-matching `grep` returns 1, the
   `if` is simply not taken, and the step exits 0 even though the tool
   call failed — the scan silently becomes the only assertion. Under
   `set -e` the opposite happens: the script dies at the assignment and
   never scans the error output, which is exactly where a leaked
   credential is most likely to appear. Now captures with `|| status=$?`,
   scans, then propagates, with a callout explaining both failure modes.

2. `@2` is a major-version range, not a pin, and it was written directly
   under a sentence claiming it pinned the version. Both occurrences now
   use an exact published release (2.5.0, the current `latest`), and the
   prerequisites note says outright that a range is not a pin. `--yes`
   added to the prerequisite form so an unattended job cannot hang on
   the first-run install prompt.

3. `--stored-auth-only` was described as failing when the store has no
   token. It is a no-op against a server that never challenges — the
   guide's own verification run passed with an empty store against a
   non-auth server. Reworded to say exit 3 happens only when a challenge
   cannot be satisfied, and added the way to actually assert that
   authentication happened: one run with an isolated empty
   MCP_STORAGE_DIR that requires exit 3.

Both corrected shell snippets were executed: secret+failure exits 1,
failure without a secret propagates exit 4.

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 13:22:49 -04:00
cliffhall
ade7b5782d docs: add a reusable CLI smoke-testing guide for v2 (#1886)
Adds `docs/cli-smoke-testing.md` — a copyable connect → list → call →
assert workflow for testing an arbitrary MCP server from a shell or a CI
job, plus index rows in the root README and a pointer from the CLI
README.

The gap this fills is a user-facing one. `clients/cli/README.md` is a
flag reference and `scripts/smoke-cli.mjs` is an internal end-to-end
check of the launcher → CLI path; neither tells someone how to compose
those flags into a check they run on every commit against their own
server.

Covers, in order: connecting over stdio/HTTP/SSE (including the `--`
separator, which splits the opposite way under `--cli`); `--format json`
and its `{result[, appInfo]}` envelope; asserting a tool exists with
`jq -e`; one representative `tools/call` and the `--tool-arg` vs
`--tool-args-json` coercion difference; the exit-code map and how to
branch on it; keeping OAuth non-interactive with `--stored-auth-only`
and an isolated `MCP_STORAGE_DIR`; and negative assertions — refusals,
a secret-shape scan of captured output, and `--strict` schema
portability. Ends with a complete script and a GitHub Actions job.

Every command and output shape in the guide was verified against the
bundled composable test servers over both stdio and HTTP rather than
transcribed from the reference, which corrected three claims that would
otherwise have shipped wrong:

- `--tool-arg zip=012` does not send `12`. `012` is not valid JSON, so
  it is sent as the literal string. The real trap is `zip=10001`, which
  does coerce to a number and is then rejected by a string schema.
- `if ! cmd; then status=$?` yields `0`, because `!` inverts the
  pipeline's status — every failure class would look identical. The
  guide uses `|| status=$?` and says why.
- The sample script does not report a failure class on its own; that is
  the opt-in `case` block from §5.

The §8 script was run end to end against a live HTTP test server, and
exits 0/3/4/5/6 were each reproduced against a real server.

Closes #1886

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 13:01:09 -04:00
cliffhall
81f2115e8b fix: decline a union branch requiring a name it never declares (#2224)
`isOfferable` in `core/json/rootUnion.ts` checked that a merged branch carries
at least one property and that every property value is readable, but never that
each name in `required` resolves to a property a form can render. A branch like
`{ properties: { kind }, required: ["kind", "payload"] }` was therefore offered
in the picker, rendered no control for `payload`, and had the submit-time check
report it missing permanently — the exact dead end the function's own comment
already reasons through for a `false`-schema required field, one case short.

Judge the merge, so a branch requiring only names the ROOT declares —
`anyOf: [{ required: ["email"] }, …]` — stays offerable as before. `hasOwn`
rather than `in`, since an argument legally named `constructor` would otherwise
resolve through the prototype and read as declared.

Declining every branch of a union leaves the web form with neither a picker nor
a field, which is a different dead end. `SchemaForm` now seeds the raw-JSON
editor (#2151) in that one case — a root union that resolved to no branches and
no properties — so the arguments stay expressible. Seeded rather than forced, so
the switch still works, and re-seeded during render (never in an effect) because
the form is reused across tools rather than remounted. A tool that genuinely
takes no arguments is untouched. The TUI has no such editor; there the union is
simply not offered, which is already better than a section that can never be
submitted.

Adds a `record_shipment_by` preset and wires it into the
`root-union-schemas-http.json` showcase so the shape is reproducible by hand,
with docs/test-servers.md covering it.

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 12:59:52 -04:00
Cliff Hall
23698afbe5
Merge pull request #2273 from modelcontextprotocol/v2/docs/2254-inline-review-replies
docs: reply to each review comment in its own thread, not in a rollup comment
2026-09-06 12:34:51 -04:00
cliffhall
ed5e98b643 docs: paginate the review-comments fetch
Copilot review round 2 on #2273: the list endpoint pages at 30, so a large
review round would silently drop later comments — and a workflow whose whole
point is replying to every comment cannot be documented with a command that
returns only the first thirty. --paginate combines with --jq fine (only --slurp
does not), verified against a live review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 12:34:20 -04:00
cliffhall
5a1b5900cf docs: a reply does not resolve a review thread
Copilot review round 1 on #2273: the text said only a reply in the thread "can
resolve it", which is wrong — resolution is a separate act (the Resolve
conversation button, or the resolveReviewThread GraphQL mutation) and it is the
reviewer's to make. Reading it as satisfied by the documented REST reply is
exactly the wrong conclusion for a PR whose whole point is what a reply does.

State what the reply actually buys: it is the only thing a reviewer reading that
thread sees, and it is what makes resolving the thread defensible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 12:34:20 -04:00
cliffhall
f504b89b56 docs: reply to each review comment in its own thread, not in a rollup (#2254)
pr-flow §8's "mirror each round at PR level" was being read as permission to
skip the inline reply, so the rollup got posted and the per-thread replies never
happened. A bullet elsewhere on the page cannot be connected back to the thread
it answers: the thread stays open showing a finding and silence, and by round
three matching bullets to comments is reconstruction rather than reading.

- AGENTS.md, "Responding to Code Reviews": says where the response goes, which
  was the ambiguity being resolved the wrong way — inline in the comment's own
  thread, with the rollup in addition, never instead.
- pr-flow §8 reorders the two so the primary is unmistakable, and keeps the
  reason the mirror exists (threads go outdated once the fix is pushed) as a
  rationale for it rather than an alternative to the inline reply.
- Both name the exception: a finding in the "Suppressed comments" block has no
  comment id and so no thread to reply into, making the mirror its only home.
- §8 gains the two mechanics, so the easy path is not the only documented one:
  the reply endpoint keyed by comment id, and fetching comments per review by
  review id, since the unpaginated /reviews listing hides later rounds behind
  your own replies.

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