Commit graph

2,946 commits

Author SHA1 Message Date
Cliff Hall
dd67164bea
Merge pull request #2136 from modelcontextprotocol/v2/fix/2110-oauth-path-scoped-discovery
fix(oauth): keep the server's path in the fallback authorization-server URL (#2110)
2026-08-26 00:44:28 -04:00
Cliff Hall
02cba7315a
Merge branch 'v2/main' into v2/fix/2110-oauth-path-scoped-discovery 2026-08-26 00:42:44 -04:00
Cliff Hall
c06dc312af
Merge pull request #2135 from modelcontextprotocol/v2/fix/2134-parsesse-chunk-boundary
fix(core): carry the in-progress SSE frame across chunk boundaries
2026-08-26 00:41:41 -04:00
cliffhall
2bb0b6245c fix(oauth): walk the candidates in the CLI refresh path too
Copilot review round 1.

`refreshStoredAuthToken` consumes the same fallback and made a single SDK
discovery call, so preserving the path there — without the walk the other two
consumers got — would have regressed exactly the case the walk exists to keep:
a path-hosted MCP server whose authorization metadata is published at the
domain root.

It injects its own discovery function as a test seam, so the walk is now also
exposed as `discoverAuthorizationServerMetadataFromCandidates(candidates,
discover)`, which takes that function and reports *which* candidate answered —
the CLI needs the winning URL, since it is the base the token request is made
against, not just the metadata.

Also drops the `as unknown as typeof fetch` from the new test in favour of
`vi.fn<typeof fetch>()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 00:34:27 -04:00
cliffhall
99f2a20eb0 style: prettier-format the parseSSE test
Caught by CI's clients/web format:check, not by my local run — I had
formatted core/ and scripts/ but not the web client's src, which is where
the test lives.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 00:23:55 -04:00
cliffhall
afa0ba6eeb fix(oauth): keep the server's path in the fallback authorization-server URL
When RFC 9728 protected-resource metadata names no authorization server, the
MCP server URL stands in as one. `getAuthorizationServerUrl` built that with
`new URL("/", serverUrl)`, which discards the path — exactly the part discovery
needs when the server is not hosted at the domain root. The SDK's
`buildDiscoveryUrls` derives the path-scoped well-known locations from that
pathname, so an origin-only value could only ever probe the domain root, which
404s for a server like `https://example.com/mod/minilesson/mcp.php`.

Keep the path (and strip query/fragment, which an RFC 8414 issuer never
carries). A straight swap would regress the case the origin-only value did
serve — a server that merely lives under a path while publishing its metadata
at the root — so the URL becomes the first of two candidates:
`getAuthorizationServerUrlCandidates` yields the path-scoped form then the bare
origin, and `discoverAuthorizationServerMetadataForServer` walks them, treating
a candidate's failure as non-fatal while another remains and rethrowing the
first error only when all of them fail.

`discoverScopes` and the CIMD pre-registration probe both go through the walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 00:03:40 -04:00
cliffhall
6cacfa3ec5 fix(core): carry the in-progress SSE frame across chunk boundaries (#2134)
parseSSE declared currentEvent/currentData inside the read loop, so an SSE
frame whose data: line arrived in one chunk and whose terminating blank
line arrived in the next was discarded. buffer already carried a partial
LINE across reads; nothing carried a partial FRAME.

Because this is the web client's transport, the dropped frame is a
JSON-RPC response that never settles: the request hangs forever with no
rejection, no console message, and nothing in the Network tab. The
payload does not arrive corrupted, it does not arrive at all, which is
why it presents as a hang rather than an error.

It has not bitten us because Chromium and Firefox happen to deliver whole
frames at the payload sizes this transport currently sees. That is luck,
not a guarantee: chunk boundaries depend on payload size, TCP
segmentation, and any intermediary. The exposure grows with payload size,
so a large resources/read is the most likely first victim.

The fix hoists both out of the loop and adds an end-of-stream flush — a
server that closes without a final blank line has still delivered a
complete frame, and dropping it would lose the last message of every such
stream.

parseSSE is now exported, solely so the test can reach it. That is the
only way to hit this deterministically: driving a real transport cannot
steer where a chunk boundary lands, but a test that supplies the reader
can put it exactly on the terminator.

The test keeps the two PASSING cases (whole frame, split mid-payload)
alongside the three that failed, because they are what make the diagnosis
precise. This is not "SSE parsing is broken" — mid-payload splits are
handled correctly today, and only the frame-terminator boundary loses
data. Verified against the unfixed code: 3 of 5 fail, all 5 pass after.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 23:59:37 -04:00
Cliff Hall
ca2846de28
Merge pull request #2131 from modelcontextprotocol/v2/fix/2095-pagination-override-by-server
fix: keep the pagination toggle on the last write across a server switch
2026-08-25 20:56:00 -04:00
cliffhall
bbffafded1 fix: resolve the servers list outside the override's state updater (#2095)
`record` read `serversRef.current` from inside the `setRecords` updater. A
functional updater must be pure -- React may defer or replay it -- so the
pairing resolved at whatever moment the updater happened to run rather than
when the record was written. A list read committing in between would then be
paired with the record as its baseline, and the very read that should have
superseded the override would instead certify it.

Read the list once, at call time, and use it for both the pairing and the
prune.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:33:14 -04:00
cliffhall
8a6bec02a7 docs: correct the toggle rollback comment for the per-server override (#2095)
The rollback's explanation still described the removed app-wide boolean and
claimed the override was applied only for the active server. That is now the
live client's rule alone: the override is keyed by this write's server and is
only ever displayed while that server is active, so it is recorded whatever
is active by the time the rejection arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:26:53 -04:00
cliffhall
7098da7473 fix: keep the pagination toggle on the last write across a server switch (#2095)
The displayed `paginatedLists` value falls back to the active `servers`
entry, and that list only advances when a `GET /api/servers` succeeds — so
once a list read fails it keeps describing disk as it was before the writes
made since. The optimistic override papered over that, but it was a single
app-wide slot cleared on every change of the *active* entry, so switching to
another server and back dropped it and the UI fell back to the stale entry.

Two things then disagreed, and the one the user could see was the wrong one:
the toggle read `off` while disk, the write tracker, and the client just
built from it all said `on`; and because the client skipped its connect-time
aggregate walk, the all-pages UI rendered an aggregate that was never
fetched, with no Load-next-page control to fill it.

Hold the override per server instead, in `usePaginatedListsOverride`, paired
with that server's entry as of when it was recorded and believed only while
the list still carries that same entry object — identity, not equality, so a
read reporting the value the override replaced supersedes it too. A server's
override then survives a switch away and back, and is still superseded by
the first successful read that rebuilds its entry.

The read cannot instead move to `lastPersistedSettings.resolve(id)`: that is
called during render, and it both reads refs and prunes superseded records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:20:57 -04:00
Cliff Hall
897f21c192
Merge pull request #2119 from modelcontextprotocol/v2/fix/2096-stdio-env-cwd-revert
fix(web): carry a landed stdio env/cwd onto the config it connects with
2026-08-25 09:19:29 -04:00
Cliff Hall
ca418b4032
Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert 2026-08-25 09:19:15 -04:00
Cliff Hall
becec95c80
Merge pull request #2118 from modelcontextprotocol/v2/fix/2117-persist-requested-scope
fix(oauth): persist the requested scope when the token response omits it
2026-08-25 09:18:44 -04:00
Cliff Hall
e5b65c7011
Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert 2026-08-25 09:18:19 -04:00
Cliff Hall
6e88797416
Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope 2026-08-25 09:18:16 -04:00
Cliff Hall
68a9d0b084
Merge pull request #2121 from modelcontextprotocol/v2/feat/1005-schema-lint
feat: flag unportable tool schemas in all three clients
2026-08-25 09:17:46 -04:00
Cliff Hall
d8a359bd28
Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert 2026-08-25 09:06:01 -04:00
Cliff Hall
720e602915
Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope 2026-08-25 09:05:57 -04:00
Cliff Hall
c228a326af
Merge branch 'v2/main' into v2/feat/1005-schema-lint 2026-08-25 09:05:51 -04:00
Cliff Hall
e562e91cb0
Merge pull request #2120 from modelcontextprotocol/v2/chore/2111-unconditional-test-server-build
chore(scripts): build test-servers unconditionally from one shared helper (#2111)
2026-08-25 09:04:55 -04:00
Cliff Hall
6af849e217
Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert 2026-08-25 08:55:35 -04:00
cliffhall
71b960e078 Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope
#2114 landed on v2/main, which is the PR this one follows up. Two
conflicts, one of them silent.

The marked conflict was cosmetic: keep v2/main's comment on the
`provider.scope` read, which now notes the getter filters (#2068).

The real one git merged cleanly and would have left dead: #2114's

  if (this.oauthConfig.requestRefreshToken === false && requestedScope) {
    this.pendingAuthorizationScope = requestedScope;
  }

sat directly above this branch's unconditional
`recordAuthorizationRequestScope`, which overwrites the same field a few
lines later. Dropping that gate is what #2117 is for, so it is removed
rather than kept as a no-op.

The two changes compose: the SDK adds `offline_access` only when
`clientMetadata.grant_types` includes `refresh_token`, which the provider
drops while the opt-out is on -- so reading the authorize URL yields the
filtered scope there, exactly what #2114's gated branch persisted.

#2114's third case pinned the gate and inverts here, as its issue
predicted. It now asserts the request is persisted even with the grant
on; that records no new claim, since the request equals what storage
already held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 08:51:45 -04:00
Cliff Hall
e005055a46
Merge branch 'v2/main' into v2/feat/1005-schema-lint 2026-08-25 08:46:11 -04:00
Cliff Hall
fc890532b0
Merge branch 'v2/main' into v2/chore/2111-unconditional-test-server-build 2026-08-25 08:43:10 -04:00
cliffhall
182c48f296 fix: stop claiming a bare-true replacement narrows the contract
Copilot review round 8 (its one suppressed finding; no new inline comments).

The `boolean-schema` suggestion for a bare `true` said the replacement
"narrows the schema deliberately". That holds in an ordinary property
position and inverts under a negating parent: `{"not": true}` rejects
every value, so swapping in `{"type": "object", …}` leaves the parent
rejecting only objects and every non-object then passes — the enclosing
contract is widened. Under `if` it redirects which branch applies rather
than tightening anything.

- Parent-aware advice under `not`: suggest `{}`, which is the exact
  object form of `true` and is already exempt from `untyped-schema` in
  that position, with the message saying why a concrete type would be
  wrong there.
- Everywhere else, and in the `untyped-schema` suggestion too, describe
  the replacement as a deliberate CHANGE of contract rather than a
  narrowing. A suggestion has no business claiming an effect it cannot
  guarantee across the positions it can print from.

Tests pin the `not`-specific advice and assert no bare-`true` suggestion
in any of the three positions contains "narrow". The CLI README's sample
report is re-captured from a real run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:41:17 -04:00
cliffhall
31bdd532a6 fix: walk contentSchema, await the error envelope, correct the README claim
Copilot review round 7.

- Add `contentSchema` to the traversal. It holds the schema the decoded
  string content must satisfy, so a bare `true` or a remote `$ref` under
  it is a real finding all three clients were walking past. Also admit
  the three content keywords to `CONSTRAINING_KEYWORDS` on the same
  documented grounds as `format` — annotation-only by vocabulary, but
  plainly an attempt to constrain, so reporting such a schema as
  accepting anything would be noise. The comment now names that
  category rather than singling `format` out.

- Make `handleError` async and await the envelope write. Round 6 made
  the `--strict` report await its write and stopped one line short: the
  envelope emitted for the very same throw still raced `process.exit()`,
  so the machine-readable line a CI caller parses could be lost on a
  pipe. The launcher's comment claiming the envelope was "well inside
  the pipe buffer" was wrong reasoning and is replaced — fitting in the
  buffer says nothing about whether the write was performed. Its call
  site now awaits; the bin's `.catch(handleError)` needed no change.

- The CLI README called its sample block the "complete output" when the
  error envelope follows it on every non-zero exit. Now scoped to "the
  complete report", with the envelope named and linked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:26:39 -04:00
cliffhall
488106f235 fix: scan only subschema positions, await stderr, show the whole report
Copilot review round 6.

- Share one traversal between `walk` and `declaresAnyId`. The `$id` guard
  added last round descended through EVERY object value, including
  instance data — `examples: [{"$id": "x"}]`, a `default`, a `const` —
  where `$id` is a payload key declaring no embedded resource. Reading
  one there suppressed `remote-ref` for the whole document, turning last
  round's false-positive fix into a blanket false negative. Both passes
  now go through `forEachSubschema`, driven by the same keyword tables,
  so they cannot drift about what counts as a schema position.

- Await the stderr writes. `writeSchemaLintReport` used a bare
  `process.stderr.write`, but both CLI exit paths call `process.exit()`
  as soon as the work returns, which discards anything still buffered on
  a piped stderr — truncating or losing the multi-block `--strict`
  report on exactly the redirected-output runs a CI caller uses. Added
  `awaitableError` beside the existing `awaitableLog` and awaited it.
  The test's fake defers its callback so a synchronous one cannot make
  the assertion vacuous; two existing stderr stubs that never invoked
  their callbacks were fixed in the same pass.

- Show the CLI README's sample report in full. It printed one error
  block immediately followed by the summary, a shape the formatter
  cannot produce — every finding gets a block. Captured from a real run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:10:37 -04:00
cliffhall
68c4abf37a fix: count severities correctly, gate remote-ref on $id, order --strict checks
Copilot review round 5.

- The sidebar tooltip applied `hasError` to the TOTAL, so a tool with one
  error and three warnings announced as "4 schema portability errors" —
  a defect the previous round introduced while adding that wording, and
  worse than the vague text it replaced. Both the tooltip and the
  accessible label now carry the breakdown, formatted by a new shared
  `summarizeToolFindings` so the fourth place to phrase a severity count
  cannot drift from the other three.

- Suppress `remote-ref` when the document declares an `$id` anywhere.
  `$id` establishes a base URI, so with a root `$id` of
  `https://example.com/root` the ref `https://example.com/root#/$defs/x`
  resolves to THIS document and needs no fetch — the finding and its
  "inline the referenced schema" remediation would both be wrong.
  Classifying correctly needs full RFC 3986 resolution against nested
  embedded resources, which this module does not do, so it declines
  rather than guessing. A missed finding is the acceptable direction.

- Run the `--strict` validations before every short-circuit return.
  `servers/list`, `servers/show`, `--list-stored-auth` and
  `--print-handoff` all return from `parseArgs` before any connect, so
  the checks further down let `--strict --method servers/list` succeed
  while silently ignoring a tools/list-only flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:54:21 -04:00
cliffhall
2cb23a8b21 fix: map the new exit code, keep add runnable, distinguish severity by shape
Copilot review round 4.

- Add `SCHEMA_UNPORTABLE` to `codeForExit`. `CliExitCodeError` lets a
  caller omit the envelope, so without the case an envelope-less throw
  reported the generic `error` — the same silent mislabelling the rename
  was meant to prevent, one layer down. Covered via the envelope-less
  construction that exposed it.

- Keep a local `type` (and the preset's `required`) alongside the remote
  `$ref` in the showcase config. The TUI's `schemaToForm` dispatches on
  `type`, so a `$ref`-only property fell through to a string field that
  `add`'s numeric handler then rejected — making the README's "every
  showcase tool is runnable" claim false there. The `remote-ref` finding
  is unaffected, since that rule reads `$ref` independently.

- Restrict `type-union` to well-formed unions: non-empty, unique, and
  all recognized JSON Schema type names. Its message asserts the
  construct is *legal* JSON Schema, which is false for `[]`, `[3]` or
  `["bananas"]`, and the `anyOf` it suggested for those would be invalid
  too. Malformed schemas stay the SDK parser's business.

- Drop `if`/`then`/`else` from the presence-only allowlist. They only
  constrain as a pair, so `{"if": {"const": 1}}` accepts every value and
  was evading `untyped-schema`; the pairing is now checked explicitly.

- Distinguish the web sidebar severities by SHAPE, not just colour — a
  circle for an error, a triangle for a warning — and name the severity
  in the tooltip. Colour alone left a colour-blind reader unable to tell
  them apart.

- Fix the stale "across 2 tools" count in the CLI README's hint sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:37:10 -04:00
cliffhall
a3d864ed6e fix: rename the strict exit code, cover the config path, refresh the docs
Copilot review round 3.

- Rename the `--strict` failure from "invalid" to "unportable"
  (`EXIT_CODES.SCHEMA_UNPORTABLE`, envelope `schema_unportable`, and the
  message text). The module argues at length that these schemas ARE
  valid JSON Schema and are merely refused by some clients, so a
  machine-readable code of `schema_invalid` told automated callers the
  opposite. Done now, while nothing depends on it.

- Add a fixture-path integration case. Every other test built a
  `ServerConfig` by hand, so `resolveConfig`'s `rawToolSchemas`
  forwarding was uncovered and `unportable-schemas-http.json` was loaded
  by nothing — the shipped fixture could have lost its overrides with
  the suite still green. It now goes through loadConfig/resolveConfig
  and asserts the exact per-tool verdict the README claims.

- Refresh the CLI README's sample output, which still showed the
  pre-round-2 wording and fixture, and so dropped the contract warning
  that rewording existed to add. Captured from a real run rather than
  written by hand, plus a paragraph making the suggestion pattern
  explicit.

- Drop the self-referential "[Try it](#unportable-tool-schemas)" link in
  the root README, which pointed back at its own section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:17:58 -04:00
cliffhall
cac97c142c fix: drop the unreachable root rule, correct the always-false suggestion
Copilot review round 2.

- Remove `non-object-root` entirely. It could never fire: the SDK types
  `inputSchema` with `type: literal("object")`, so a tool with any other
  root fails `ListToolsResultSchema` and `salvageListItems` drops it
  before any client sees it. Verified against a live server advertising
  `inputSchema: {type: "array"}` — `listAllTools()` returns []. A rule
  that cannot fire is worse than none, because the docs then claim a
  check the tool does not perform; the condition is already reported
  through the malformed-items surface. Pinned by an integration test so
  a change in that behavior is a signal rather than a silent gap.

- Fix the bare-`false` suggestion. `properties: {a: false}` forbids the
  property; deleting the entry *permits* it with any value under the
  default `additionalProperties`, so the advice inverted the schema it
  replaced. Now recommends `{"not": {}}` and says why deletion differs.

- Exempt `not`-parented schemas from `untyped-schema`, so the `{"not":
  {}}` above does not trip this module's own rule — under negation an
  always-accepting subschema means always-reject, making "accepts any
  value" the exact opposite of what it does. Scoped to that position,
  not inherited by its subtree.

- Say plainly, in the `true` and untyped suggestions, that they narrow
  the schema deliberately rather than implying an equivalent rewrite.
  `true` has no portable object-form equivalent — `{}` is the very shape
  this lint flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:02:26 -04:00
cliffhall
a2e8d42f49 chore(scripts): drop the plumbing the consolidation left dead
Round 4. Two leftovers from moving the build into the shared helper, both
invisible here because `scripts/` is prettier-gated but not ESLint-gated,
so an unused binding fails nothing:

- smoke-web-elicitation.mjs imported `existsSync` solely for the local
  early return that is gone.
- pack-and-verify.mjs kept `composableServer` and its `composableServerPath`
  import for the same reason; the helper now asserts that entry via
  `requires: ["composable"]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:50:52 -04:00
cliffhall
c389565783 fix: address Copilot review on the schema portability lint
- Scope `non-object-root` to `inputSchema`. The pinned SDK types
  `inputSchema` with `type: literal("object")` but `outputSchema` as a
  bare `looseObject`, so a `{"type":"string"}` output is conforming; the
  rule was claiming a requirement MCP does not make, at error severity.

- Decide `untyped-schema` from an allowlist of constraining keywords
  rather than a denylist of annotations. JSON Schema ignores keywords it
  does not recognize, so `{"vendorHint": true}` accepts every value and a
  denylist passed it as constrained. `$defs`-only schemas now flag too.

- Walk `dependentSchemas` and draft-07 `dependencies`, with a test
  pinning that the property-name-array form is not a schema position.

- Suggest `anyOf` branches for an array-form `type`, never "drop it from
  `required`" — accepting `null` and permitting absence are independent
  contracts, so the old suggestion silently changed the schema's meaning.

- Reject `--strict` alongside `--app-info`. That path returns NDJSON from
  `runMethod` and never reaches the lint, so the flag was a no-op gate
  that could never fail.

- Colour the web sidebar flag from the `--inspector-*` severity tokens,
  matching the detail panel and avoiding the default yellow that failed
  the a11y check.

- Add a live integration test for `rawToolSchemas`. It caught that an
  `outputSchema` override on a preset returning no structured content
  makes every call to that tool fail client-side validation — the
  showcase config did exactly that, so the flagged tool could not be
  run. Moved the bare `true` onto `get_temp`, documented the caveat, and
  pinned both halves.

- Drop the double cast from the lint test fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:41:12 -04:00
cliffhall
3222537e73 docs(scripts): correct the deleted-source caveat — it is silent, not loud
Round 3's suppressed finding, and it corrects a claim of mine that was
simply wrong. The header said a deleted source file's stale `.js` produces
"a loud failure rather than a silent one — an import of a removed module
fails at the consumer". It does not: the stale file is still there, so the
post-build existence checks pass against it and anything still importing
that module keeps silently running the old code. That is the SAME failure
class this PR exists to close, described as though it were the opposite.

Rewritten in the helper header and AGENTS.md to say the unconditional build
does not cover deletion, and to make `rm -rf test-servers/build` after a
delete or rename an instruction rather than an aside — which is now a real
clean, since the tsbuildinfo was pinned inside build/ in 8d19346.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:37:40 -04:00
cliffhall
c6029e1cbb fix(oauth): persist the granted scope on every EMA mint
`trySilentEmaAuth` and `refreshEmaResourceTokens` saved tokens without
ever saving a scope. Both are reached from real paths -- initial EMA
authentication with a cached IdP session, refreshEnterpriseManagedTokens,
and EmaTransportOAuthProvider.tokens -- and each can be the moment a
newly configured or metadata-derived scope first takes effect, so a
silent token response left the previous stored scope standing.

Rather than a third copy of the rule, every EMA mint now ends in one
place: mintEmaResourceTokens reports the scope it requested, and
saveMintedTokens writes tokens and the RFC 6749 5.1 scope together.
The manager's EMA branch drops its own saveScope, which would now be a
redundant second write of the same value; the step-up branch keeps its
own, since it falls back to the challenge's authorizationScopes union
and runs last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:32:40 -04:00
cliffhall
77e978f318 docs(scripts): correct smoke-web-app's stale "built on demand" header
Round 2's suppressed finding, and a fair one: smoke-web-app.mjs reaches the
build through `startMcpAppServer` rather than calling the helper itself, so
its header survived the sweep still claiming `test-servers/build` is "built
on demand if missing" — the exact policy this PR removes. It is also the
smoke that found the defect, so the header now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:24:40 -04:00
cliffhall
2f9e0901c1 feat: flag unportable tool schemas in all three clients
Closes #1005

A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.

Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.

Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.

`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:18:53 -04:00
cliffhall
9a2616652c fix(oauth): record every redirect's scope, and what EMA really minted
Two more paths had the same defect, both found in review.

`handleStandardAuthChallenge` returns an interactive redirect for
`unauthorized` / `token_expired` / `invalid_token` as well, and its
callback lands in the same completeOAuthFlow -- but the recording was
gated on `insufficient_scope`. Those challenges compute their own
`scopeForAuth`, which can differ from what storage holds, so a silent
token response left the stale scope standing. The read is now one
private helper both entry points call, applied to every redirect.

And `scopeForMint` is not always what the EMA mint asks for: with
neither config nor storage supplying a scope, discoverEmaResourceContext
resolves it from the protected resource metadata's `scopes_supported`,
which is what both exchanges send. `completeEmaIdpAuthorizationAndMint`
now resolves that context itself -- passing it into the mint, so no
extra discovery -- and returns it as `requestedScope` for the manager to
use as the RFC 6749 5.1 fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:15:55 -04:00
cliffhall
8d193461e3 fix(scripts): pin the tsbuildinfo inside build/, assert on cache hits
Two findings from review round 1, both real.

The default incremental cache path derives from `rootDir`, so it landed at
`test-servers/tsconfig.tsbuildinfo` — beside the config, OUTSIDE `build/`.
That survives the `rm -rf test-servers/build` this project documents as the
remedy for a deleted source file, leaving tsc convinced its outputs are up
to date and emitting nothing. Pinned to `./build/tsconfig.tsbuildinfo`, and
verified that removing the output dir now forces a full re-emit.

The build is memoized per repo root; the existence assertion was memoized
with it. A later caller naming an entry the first did not would be handed a
path tsc never emitted, then fail with exactly the opaque
ERR_MODULE_NOT_FOUND the helper exists to replace. The assertion now runs on
every call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:11:10 -04:00
cliffhall
8be48eb742 fix(oauth): persist the scope the authorize URL carried
`provider.scope` is not what the authorization request asked for. The
SDK augments it with `offline_access` when the AS advertises that scope
and the client wants a refresh token, so persisting the provider's copy
after a silent token response understates the RFC 6749 5.1 implied
grant -- the same defect this change exists to fix, one notch smaller.

Read the scope off the captured authorize URL instead, falling back to
`provider.scope` when the URL carries none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 00:58:07 -04:00
cliffhall
e59452ea8c chore(scripts): build test-servers unconditionally from one shared helper
Five `scripts/*.mjs` consumers each carried their own `ensureTestServer()`
that returned early when `test-servers/build` was already on disk. Presence
is not freshness: once that directory exists — which it does on any machine
that has run the smokes once — an edit to `test-servers/src` was never
picked up again, and the smoke went on driving the previously-built fixture.

The failure is silent and misattributed. It does not read as "your fixture
is stale"; it reads as a product bug in whatever the smoke is checking.

`scripts/lib/ensure-test-servers.mjs` is now the one place that build is
produced for a script consumer, and it builds unconditionally — memoized
once per process per repo root, so pack-and-verify's two asks stay one tsc
pass while a fresh run always rebuilds. Callers name the entries they
depend on and get one actionable failure message, replacing five that had
drifted. pack-and-verify also stops shelling out to `npx tsc`, which is
what made it the one script unrunnable on Windows (#1939).

`test-servers/tsconfig.json` gains `incremental` so the up-to-date case is
cheap: 3.0s cold, 1.6s incremental here.

Unconditional emit is still not a clean — `--noCheck` leaves the stale .js
of a deleted src file behind — which is unchanged from before and recorded
in the helper's header and AGENTS.md.

Closes #2111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 00:57:48 -04:00
cliffhall
d9b5f4f8a7 fix(web): carry a landed stdio env/cwd onto the config it connects with
The Server Settings modal edits a stdio server's `env` and `cwd` as
settings, while the transport reads them off the *config* — so #2091's
tracker, which resolves settings through the last write known to have
reached disk, left those two behind: `server.config` still came straight
off a `servers` entry that stops advancing once a list read fails. A save
that landed during such a window kept spawning the child process with the
pre-save environment, while the modal (re-seeded from the tracker) showed
the new value. Credentials live in `env`, so the symptom is the inspected
server failing to authorize rather than a visibly lost edit.

Adds the settings→config mapping to `core/mcp/serverList.ts` — the one
place both sides of the wire can share — and routes the `/api/servers` PUT
write-through and the web client's connect path through it, so the value
persisted and the value spawned cannot be derived differently.

Closes #2096

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 00:36:09 -04:00
cliffhall
db23bc9b97 fix(oauth): persist the requested scope when the grant omits it
RFC 6749 5.1 defines an omitted `scope` in a token response as a grant
identical to what was requested, and `resolvePersistedScopeAfterGrant`
already encodes that rule -- but the ordinary authorization leg never
gave it a `requestedScope` to fall back to, so nothing was persisted and
the previous stored scope kept being reported as what the current token
carries.

`authenticate()` now records `provider.scope` as the pending
authorization scope once the authorization URL has been captured, so it
survives the redirect and reaches `completeOAuthFlow`. The
enterprise-managed leg had the same defect for the same reason and is
fixed one level up: it now falls back to `scopeForMint`, which is what
the mint actually asked for, rather than to the raw
`pendingAuthorizationScope` that is undefined on this leg.

A response that does carry `scope` still wins over the request, on both
legs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 00:35:10 -04:00
Cliff Hall
a9f37381d5
Merge pull request #2114 from modelcontextprotocol/v2/feat/2068-refresh-token-toggle
feat(oauth): make the refresh_token grant opt-out per server
2026-08-25 00:29:31 -04:00
Cliff Hall
40cfb51f70
Merge branch 'v2/main' into v2/feat/2068-refresh-token-toggle 2026-08-25 00:08:40 -04:00
Cliff Hall
2b129087a3
Merge pull request #2115 from modelcontextprotocol/v2/fix/2108-oauth-failure-monitor
fix(web): open the monitoring sidebar when a connect fails during OAuth
2026-08-25 00:08:01 -04:00
cliffhall
99a0748355 docs: say when the filtered scope reaches storage
The self-healing claim was still overstated after b339ad49 fixed the
code: storage is untouched at filter time, but it does update on the
next successful grant, including when the token response omits `scope`.

Markdown only — prettier ran clean; pushed without the full gate on the
maintainer's call, since `npm run ci` checks formatting rather than
prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 23:55:33 -04:00
cliffhall
b339ad49f0 fix(oauth): persist the filtered scope after an ordinary grant
Copilot review round 11 on #2114. The docs claimed the persisted scope
"self-heals on the next grant"; on the ordinary authorization leg it
did not.

`authenticate()` never set `pendingAuthorizationScope` — only the
step-up paths did — so `completeOAuthFlow` called
`resolvePersistedScopeAfterGrant(tokens.scope, undefined)`. When the
token response omits `scope` (RFC 6749 §5.1: omitted means the grant
matched the request), the fallback was undefined, nothing was saved,
and the stale `offline_access` stayed in storage where
`resolveEffectiveGrantedScope` keeps reporting it as granted.

`authenticate()` now records the scope it actually requested, read once
from the filtering getter so what is recorded is what went out.

Gated on the filter being active rather than applied unconditionally.
The general version is arguably more RFC-correct, but it would change
what lands in storage for every server that never touched this setting,
inside a PR whose contract is that nothing changes unless the box is
unchecked. With the grant declared, request and storage agree and there
is nothing to heal.

Three tests; the omitted-scope one fails when the recording is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 23:52:14 -04:00
cliffhall
fef8f5ca5a fix(oauth): pass the raw challenge scopes to the step-up scope filter
Copilot review round 10 on #2114 — a defect in round 8's fix, and the
precise loop that fix's extra argument was added to prevent.

`enrichChallengeWithScopes` narrows `requiredScopes` to the *missing*
subset, so a challenge requiring `offline_access tools:write` against a
stored scope that already carries `offline_access` arrives as just
`["tools:write"]`. Passing that narrowed list made the filter read the
server's explicit requirement as inherited and strip it, so
re-authorization would earn the same challenge forever.

Passes `challenge.requiredScopes` — the raw list — instead. The mixed
case is now covered, and fails against the narrowed list.

Also stops the checkbox promising more than the setting delivers: it
does not register anything for a preconfigured or CIMD client, and the
authorization server decides issuance either way. It now says it
declares the grant so the server *may* issue a refresh token.

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