Commit graph

2,679 commits

Author SHA1 Message Date
Cliff Hall
b020709d51
Merge pull request #2050 from modelcontextprotocol/v2/fix/2049-unhandled-list-reload-rejections
fix: handle background command rejections in App (#2049)
2026-08-18 12:53:12 -04:00
cliffhall
9f42a5ef10 test: narrow the fake-client cast to a single assertion
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 10:52:24 -04:00
cliffhall
f96c4583ee fix: handle background command rejections in App (#2049)
`runWithCommandAuthRecovery` rethrows anything that isn't an
`AuthRecoveryRequiredError`, and 17 of its 18 call sites were bare
`void runWithCommandAuthRecovery(...)` — so any non-auth failure of a
user-initiated list reload (Retry, Load next page, the pagination-mode
toggle) became an unhandled rejection in the browser.

Neither gate that should catch it could: `void` is the sanctioned
suppression for `no-floating-promises`, and `smoke:web:browser` — which
hard-fails on exactly this signal — never connects to a server.

Add `runCommandInBackground`, a fire-and-forget wrapper that owns the
rejection, and route every background command through it, so a new call
site cannot reintroduce the gap by omission. An optional `errorTitle`
picks the reporting: omitted where the list panel already renders the
failure with a Retry (a toast would duplicate it), passed where nothing
else records it — `logging/setLevel` and the tasks refresh.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 10:47:33 -04:00
Cliff Hall
7be5875dc5
Merge pull request #2047 from modelcontextprotocol/v2/fix/1998-paginated-list-errors
fix: surface list-fetch failures in paginated mode (#1998)
2026-08-18 08:20:56 -04:00
cliffhall
de60983ccc fix: surface list-fetch failures in paginated mode (#1998)
#1954 wired the list-load error off the aggregate managed stores only.
With `paginatedLists` on, those stores deliberately skip their all-page
walk, so their error is permanently null — while the paged stores that
actually drive the sidebar had no error state at all and `loadPage` had
a try/finally with no catch. A failing list showed an empty panel with
no alert and no Retry, and the connect-time `void loadPage(undefined)`
left an unhandled rejection.

- Give PagedTools/Prompts/ResourcesState observable error state: an
  `errorChange` event plus `getError()`, set in a new catch in
  `loadPage`, cleared on the next success and on disconnect. The
  rejection is still re-thrown, matching ManagedListState — callers'
  auth-recovery wrappers key off it to detect a 401.
- Catch the connect-time load's rejection (`.catch(() => {})`) now that
  the failure is recorded as state rather than lost.
- Select the error by mode in `usePaginatedList`, the same way `items`
  already is, and read it in App.tsx from the pagination model.
- Rename `useManagedListError` to `useListError`: both store families
  expose an identical `errorChange`, so the hook declares that one-event
  contract itself instead of importing ManagedListEventMap.

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-17 20:40:23 -04:00
Cliff Hall
885228699e
Merge pull request #2045 from modelcontextprotocol/v2/fix/1999-sse-onabort-before-priming
fix: register SSE abort cleanup before the priming write
2026-08-17 19:06:01 -04:00
cliffhall
e58c80440b fix: guard the abort listener against a double invocation
Copilot review round 1. The helper's contract is exactly-once teardown
and the test asserted it must not lean on Hono's own re-entry guard, but
neither the implementation nor the test actually enforced that: the
listener had no local guard and the test aborted only once.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:41:24 -04:00
Cliff Hall
86245a4a83
Merge pull request #2044 from modelcontextprotocol/v2/chore/2043-eslint-ignore-web-build
chore: ignore the web client's tsup build output in ESLint
2026-08-17 14:35:04 -04:00
cliffhall
3dfac1c3db fix: register SSE abort cleanup before the priming write
Both SSE handlers installed their subscriber, primed the stream so
Firefox's fetch() resolves (#1858), and only then registered onAbort.
Hono's onAbort has no already-aborted replay and abort() fires only the
subscribers registered at that moment, so a client disconnecting while
the priming write was in flight fired nothing: the session event
consumer / server-list subscriber stayed installed, the mcp.json watcher
was never stopped, and the handler parked forever on a promise nothing
would resolve.

Add primeAndHoldSseStream, which registers one listener before the first
await — it both cleans up and releases the hold, collapsing the previous
duplicate onAbort registrations. Priming still happens after the
subscriber is installed, since callers treat the stream's first bytes as
proof they are subscribed.

Closes #1999

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:30:58 -04:00
cliffhall
f8cf39ca4c docs: say two coverage guards, not three
verify:dep-lockstep is a durable guard but not a coverage guard — the two
coverage guards are verify:format-coverage and verify:typecheck-coverage,
which is the terminology the rest of the repo uses. The sentence said three
and then named two.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:18:05 -04:00
cliffhall
4fd7f39acf docs: state that build output is never a lint/format/typecheck target
Adds an AGENTS.md rule under the pre-push gate naming the gated surface
(first-party source) and everything excluded from it (each client's build/,
web's dist/ and storybook-static/, coverage/, test-servers/build,
core/**/{build,dist}, *.tsbuildinfo), why gating generated output is harmful
even when it only warns, and the fact that the three coverage guards assert
the converse and so cannot catch this drift. Mirrored into
.github/copilot-instructions.md per the maintenance rules.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 10:14:29 -04:00
cliffhall
6b27e5f932 chore: ignore the web client's tsup build output in ESLint
clients/web emits two bundles — dist (the Vite SPA) and build (the tsup
prod-server runner) — but only dist was in globalIgnores, so `npm run lint`
parsed ~1.2MB of generated output and reported an unused-eslint-disable
warning from inside the vendored undici source. Its three sibling clients
already ignore their build/ output.

Closes #2043

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 09:45:57 -04:00
Cliff Hall
5dd038eb21
Merge pull request #2041 from modelcontextprotocol/v2/fix/2001-duplicate-tool-selection
fix(web): key tool selection by row identity so duplicate tool names are individually inspectable
2026-08-17 08:19:42 -04:00
Cliff Hall
f8d8c77783
Merge pull request #2035 from modelcontextprotocol/v2/fix/1919-rfc6570-uri-template-expansion
fix(web): expand resource templates per RFC 6570
2026-08-17 08:17:54 -04:00
cliffhall
7bcf155e06 fix: scope the value ceiling to the names a template references
Addresses Copilot's round-19 review on #2035 -- a divergence introduced by
my own round-14 guard rather than by the original code.

The SDK validated a value inside `encodeValue`, i.e. only after looking one
up by a DECLARED name. The restored guard checked the caller's whole map, so
an unrelated entry failed a template that never mentions it. Measured on
9caac43d:

  tryExpandUriTemplate("x://{id}", { id: "7", stale: "a".repeat(1_000_001) })
    =>  error: The value for "stale" exceeds the 1000000-character limit

RFC 6570 ignores an extra variable, so this refused a perfectly valid read.
`valueLengthError` now takes the names to consider and both callers pass the
template's declared set.

Copilot's second half is worth taking on its own: strict expansion parsed
the template THREE times -- inside `templateError`, again for the name list,
again to expand -- and the template is server-controlled and may be 1 MB.
It is now parsed once and reused, with validation split into
`sdkTemplateError` (the constructor's unclosed-brace and length/count
limits) and `partsError` (this module's own grammar), so `templateError` and
strict expansion share both halves rather than duplicating either.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:54:37 -04:00
cliffhall
9caac43da4 fix: accept only well-formed UTF-8 sequences, and validate before previewing
Addresses Copilot's round-18 review on #2035. Two suppressed comments, both
holes in my own recent fixes rather than in the original code.

1. Round 16's grouping keyed off the length-announcing lead byte alone, so
   it accepted sequences RFC 3629 forbids -- and the comment claiming they
   fell back to per-triplet counting was simply wrong. Measured on 05bbfe50:

     x{+v:1}  "%C0%80x"        =>  "x%C0%80"        overlong
              "%E0%80%80x"     =>  "x%E0%80%80"     overlong
              "%ED%A0%80x"     =>  "x%ED%A0%80"     UTF-16 surrogate
              "%F4%90%80%80x"  =>  "x%F4%90%80%80"  past U+10FFFF

   The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with
   A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so
   each of the above falls back to one triplet, which is what the fallback
   was always documented to do. The legal boundary cases (%E0%A0%80,
   %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges
   cannot quietly start splitting valid characters.

2. The preview never ran the read's template validation. It expands
   expression by expression, so `x://{a}}/{b}` with both fields filled
   previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse.

   Validation moved into an exported `templateError` that both callers
   consult, so they cannot drift. It subsumes the narrower rule round 12
   added here ("leave the invalid part standing"), which still let the REST
   of such a template expand: the refusal is per template, not per
   expression, so the preview now shows the template verbatim -- literals
   unencoded included, since half-normalizing an unreadable template invents
   a string the server never published. Two existing tests asserted the
   weaker behavior and now assert this one.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:40:50 -04:00
cliffhall
05bbfe5074 fix: read the completions map by own property, not through the prototype
Addresses Copilot's round-17 review on #2035. A real crash, and a class the
earlier prototype work missed rather than a narrower version of it.

`toString`, `constructor` and `__proto__` are valid RFC 6570 variable names,
and `completions` starts as `{}`. So for such a name:

  completions["toString"] ?? []   =>  the prototype's FUNCTION

`??` catches only null and undefined, so that function went to Mantine's
Autocomplete as its `data`, and the field crashed on first render with
`TypeError: data.map is not a function` -- reproduced by reverting the fix
under the new test.

The earlier rounds fixed this for the *values* map, which is seeded with
every declared variable and so always has the key as an own property. The
completions map is the one name-keyed map this component reads WITHOUT
having seeded it, which is what made it the surviving instance. Both reads
now go through `Object.hasOwn` -- the render, and the stale-dropdown check
that clears a variable's options on the next keystroke.

Only the autocomplete branch could reach it, so the regression drives that
branch: completions supported, a `{toString}` template, type and submit.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:25:58 -04:00
cliffhall
7d9410cbe0 fix: keep a pct-encoded UTF-8 sequence whole, and bound the preview too
Addresses Copilot's round-16 review on #2035. Two suppressed comments, both
real, and both refinements of fixes from the previous two rounds rather than
defects in the original code.

1. Round 11 settled on "a pct-encoded triplet counts as one character", but
   a multi-octet character spans several triplets -- `%C3%A9` is one `é`.
   Measured on a3dd8563:

     x{+v:1}  v = "%C3%A9x"  =>  "x%C3"   a lone lead byte, decoding to nothing

   `prefixUnits` now recognizes a well-formed pct-encoded UTF-8 sequence from
   its lead byte (RFC 3629: C2-DF two octets, E0-EF three, F0-F4 four;
   continuations 80-BF), longest alternative first so a four-octet sequence
   is not read as a two-octet one followed by loose triplets.

   A malformed sequence -- a lead byte with too few continuations, or a stray
   continuation -- matches no sequence alternative and falls back to
   per-triplet counting. That is the conservative direction: it counts as
   MORE units, so truncation keeps less rather than emitting octets that
   were never in the value.

2. Round 14's per-value ceiling only covered the read. The panel calls
   `previewUriTemplate` on every keystroke, and that path expands expression
   by expression, so it reached the allocation-heavy encoders with the same
   oversized value -- submission refused while the UI froze on the paste
   anyway. Both paths now share one exported `valueLengthError`, checked
   before any encoding happens.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:13:17 -04:00
cliffhall
a3dd856380 test: pin the InspectorClient wiring, and correct a false claim about the SDK
Addresses Copilot's round-15 review on #2035.

1. `readResourceFromTemplate` had no regression distinguishing this module
   from the SDK expander it replaced: the existing test uses `report.txt`
   and the transport tests expand separately and call `readResource`, so all
   of them pass either way.

   Writing that test corrected something I had asserted twice in this PR.
   The first version used `foo/bar` -- and verified by reverting the wiring,
   it still passed. `encodeURIComponent` DOES encode `/`, so the SDK expands
   `foobar://events/{topic}` to `foobar://events/foo%2Fbar` exactly as this
   module does. #1919 was the WEB PANEL's own `String.replace`, not the SDK
   expander, which the TUI path was using correctly all along.

   The test now uses `a!b`. `!` is a sub-delim: RFC 6570 requires it
   encoded, `encodeURIComponent` leaves it bare, so the SDK returns
   `foobar://events/a!b` and the assertion fails. Re-verified by reverting
   the wiring -- 2 of the 10 tests fail (this one and the malformed-template
   refusal), where before the change none did.

2. The module summary said "shared by every client" a dozen lines above the
   paragraph explaining the CLI is not a consumer. Now says web and TUI.

Also records, on MAX_VALUE_LENGTH, the audit prompted by round 14: the
per-value ceiling was the ONLY guard lost with `UriTemplate.expand`. The
template-length (1e6), expression-count (1e4) and variable-name-length (1e6)
limits all live in the SDK's `parse()`, which runs from the constructor --
and strict expansion still constructs a `UriTemplate`, so they remain
enforced. The restored limit is the SDK's `MAX_VARIABLE_LENGTH`, read from
the pinned dist rather than assumed.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:59:30 -04:00
cliffhall
c6b17c5241 fix: refuse a stray brace, bound value length, and drop a quadratic scan
Addresses Copilot's round-14 review on #2035. Three suppressed comments,
all real; each measured on 1c595e96 before acting.

1. A brace surviving in a literal is an unmatched one, and not a legal
   literal character. The SDK's constructor rejects an unclosed `{` but
   reads a stray `}` as text, and this parser did the same:

     expandUriTemplateStrict("x://a}", {})  =>  "x://a}"

   -- a "URI" carrying a brace, with the panel enabling the read on it.
   Both halves are now refused the same way, so the withhold behavior
   covers the whole class rather than the half the SDK happened to catch.

2. Replacing `UriTemplate.expand` also removed its 1,000,000-character
   per-value ceiling. Nothing else bounded what reached the
   allocation-heavy encoders, so a pasted or programmatically supplied
   megabyte-plus value went straight through. Restored at the SDK's own
   limit -- the guard it was, not a new policy -- and the error names the
   offending variable.

3. `templateVariables` re-scanned an expression's whole varspec list for
   every one of its names to compute `conforming`, which is quadratic in a
   multi-name expression. Templates are server-controlled and the SDK
   admits one up to 1 MB, so a single expression can carry thousands of
   short comma-separated names, and this runs on the render thread.
   Iterating the varspecs supplies the name and its conformance in one
   pass.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:47:19 -04:00
cliffhall
1c595e96ee fix: dedupe a repeated name in a group, and name every unmet requirement
Addresses Copilot's round-13 review on #2035. Two suppressed comments, both
real, both in the required-group work from round 7 rather than in the recent
preview rework.

1. `{a,a}` is ONE requirement named twice, but `requiredGroups` stored it as
   a two-name group, so everything downstream read it as "either of these
   will do". The TUI was left with a form it could not submit: its
   `length === 1` test marked `a` optional while ResourceTestModal's guard
   still refused a blank. The web panel offered the useless hint
   "Any one of: a, a". Names are now deduplicated within the expression --
   expansion still emits both occurrences (`x://{a,a}` -> `x://1,1`), which
   RFC 6570 requires.

2. The per-field hint used `find`, so a variable in several shared groups
   advertised only the first. With `{a,b}{b,c}{a,c}`, filling `b` satisfies
   the first two and every visible hint then looks met while Read Resource
   stays disabled on the unmet `{a,c}` -- a requirement with nothing on
   screen pointing at it.

   Fields now hint every shared group they sit in. That alone is not enough,
   because no per-field hint can say WHICH group is still outstanding, so
   the form states it directly: "Still needed: a or c", built from the same
   `unmetRequiredGroups` the submit gate uses, so the message and the button
   cannot disagree. `hasRequiredValues` is no longer called here -- the
   panel derives both from the one list.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:33:52 -04:00
Cliff Hall
7bda38220d
Merge pull request #1997 from modelcontextprotocol/v2/fix/1939-typecheck-coverage-windows
fix: resolve node bins via package.json instead of spawning npx .cmd shims so verify/smoke scripts run on Windows
2026-08-17 00:31:09 -04:00
cliffhall
3b995f6658 docs(spec): describe tool selection by row key, not name
`v2_ux_interfaces.md` and `v2_storage.md` still prescribed `selectedName` /
`selectedToolName` and `tools.find(t => t.name === …)` — the exact shape that
made a duplicated tool name uninspectable. Point them at `toolRowKey` /
`findToolByRowKey` and note the split between UI identity (the row key,
carried by `onSelectTool`) and wire identity (the protocol name, carried by
`onCallTool`).

Follow-up to #2001 review feedback.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:23:26 -04:00
cliffhall
6a345ae77c fix: keep the URI preview from throwing or promising an unsendable URI
Addresses Copilot's round-12 review on #2035. Both halves are regressions
from my own round-10 rework, which replaced "expand a rewritten template
through the lenient expandUriTemplate" with a part-by-part assembly and so
lost two properties that implementation had for free. Reproduced on
c6fb6271:

  previewUriTemplate("x://{v}",  { v: "\ud800" })  =>  throws URIError
  previewUriTemplate("x://{a,}", { a: "1" })       =>  "x://1"

The first is the worse one: the preview runs during render, so an escaping
throw unmounts the whole panel rather than disabling its button -- and an
unpaired surrogate is real input, since `encodeURIComponent` raises URIError
on one and a paste can deliver it. The panel already reports that case
properly through `tryExpandUriTemplate`; the display path now degrades to
the raw template instead of taking the panel down with it.

The second promised a URI the user could never send: `{a,}` is an invalid
template whose submission is refused, but `a` survives into the
expression's varspecs, so filling it previewed `x://1`. An invalid
expression is now left standing as written, which is what the rest of the
panel already says about it.

Tests for both, plus one asserting the valid expressions around an invalid
one still preview.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:22:09 -04:00
cliffhall
63b37e05ec fix(web): drop a pre-#2001 selectedToolName on OAuth resume
A resume snapshot written before this change carries `selectedToolName`
where `ToolsUiState` now expects `selectedToolKey`. The blind cast in
`restoreTabUiFromSnapshot` carried it through as a stray field that would be
re-serialized on the next redirect. Route the Tools entry through a
`normalizeToolsUi` that drops it explicitly instead.

The name is deliberately not migrated to a row key: a snapshot lives for one
redirect (so this only arises when the app is upgraded mid-flight), and the
tools list is fetched after reconnect, long after restore — there is nothing
to resolve the name against. The selection is lost; search, form values, and
the run-as-task toggle restore intact.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:19:22 -04:00
cliffhall
c93552139c fix(scripts): keep the resolver local, complete the cmd.exe quoting
Copilot review round 3, all three real:

- `require.resolve.paths()` also appends Node's GLOBAL FOLDERS
  ($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any
  NODE_PATH entries, so round 2's lookup could satisfy a missing repo
  install from a GLOBALLY installed typescript/vite — measuring the
  programs with the wrong compiler instead of producing the actionable
  "run npm install" this helper promises. Filter to the node_modules
  directories on fromDir's own ancestor chain.
- The quoting predicate missed characters cmd.exe still parses, notably
  parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all
  — expansion happens before quotes are processed. Extracted to
  lib/win-shell-args.mjs with a broad predicate and a hard error on `%`
  rather than a silently different path. `platform` is a parameter so the
  Windows behavior is exercised on the POSIX machines that run the suite.
- AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does
  not, and cannot — its children are `npm` and the installed `.bin` shim.
  State the exception and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:18:40 -04:00
cliffhall
c6fb627142 fix: count a pct-triplet as one character under every operator
Addresses Copilot's round-11 review on #2035, which corrects my own round-9
fix rather than the original code.

That fix grouped pct-triplets only under `+` and `#`, on the reasoning that
every other operator escapes a `%` anyway, so `%61` is three characters
rather than one. Wrong, in a way the narrower rule made visible one operator
over -- measured on 2e06b369:

  x/{v:1}  v = "%61%62"  =>  "x/%25"     truncated to a `%` that was never
                                          a character of the value

RFC 6570 2.4.1 counts characters and says a pct-encoded triplet counts as a
single one, without reference to the operator. Grouping per-operator also
made the same input mean two different things: `%61` indivisible under `+`,
three loose characters under `{v}`. What the operator decides is how a
retained unit is *encoded* afterwards -- a simple expansion still escapes
the kept triplet's `%`, so `%61` goes out as `%2561` -- not what counts as
one character.

`prefixUnits` no longer takes an operator, and the test that codified the
old split now asserts the uniform rule across simple, named, and reserved
expansion.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:03:10 -04:00
cliffhall
ab40fb621a fix(web): key tool selection by row identity, not name
#1961 keyed the sidebar's React children by source position, but everything
downstream still identified a tool by `tool.name`. With a `tools/list` that
repeats a name, both copies highlighted at once, the click guard made the
other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to
the first — so a later copy rendered but could never be opened.

Thread the row key through selection instead:

- `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and
  adds `findToolByRowKey`, which compares computed keys rather than parsing
  one so the two can't drift.
- `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click
  guard compare keys, and `onSelectTool` emits the row key.
- `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with
  both lookups going through `findToolByRowKey`. Renamed through
  `screenUiState.ts` and `App.tsx`'s `onToolsUiChange`.

`onCallTool` still sends `selectedTool.name` — the duplicated name is the
genuine wire identity; only the UI identity needed to be unique.

Also prefixes the excluded (SEP-2243) rows' keys: they render into the same
`Stack` as the list rows but index within their own list, so an excluded tool
sharing a name and index with a listed one collided in the key space.

Closes #2001

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:02:13 -04:00
cliffhall
b399bc64e1 fix(scripts): find the manifest without exports, quote shell args on Windows
Copilot review round 2 (suppressed comments), all three real:

- `require.resolve("<pkg>/package.json")` is governed by the package's
  `exports`, and Node keeps no special case for `./package.json` — so a
  package declaring an `exports` map without it threw
  ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and
  spawnable, a false "not installed" pointing at `npm install` for a
  package already on disk. Walk `resolve.paths()` instead, which never
  consults `exports`. The docblock claiming the opposite is corrected.
- `shell: true` (needed at all on Windows to start the `.cmd` shims) makes
  Node hand `cmd.exe` one space-joined string, so an argument holding a
  path with a space splits in two. Every generated path in
  pack-and-verify lives under `tmpdir()`, which sits beneath the user
  profile. Quote them via `shellArgs()` at all four spawn sites, the
  command path included — the hazard predates this PR, so fix the class.
- Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's
  scripts/ tree, its tests in the README's `test:scripts` row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:01:51 -04:00
cliffhall
2e06b369f4 fix: encode literals per RFC 6570 3.1, and label the varname tolerance
Addresses Copilot's round-10 review on #2035. One inline comment, one
suppressed; both real.

1. Literals were copied through verbatim, but RFC 6570 3.1 requires them
   pct-encoded on expansion -- the conformance case `café/{var}` expands to
   `caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw
   UTF-8 in the path; since this module replaced the SDK's expander for both
   clients, nothing else was going to encode it. (The pinned SDK does not
   either -- it returns `café/value` too -- so this is a gap we inherited
   rather than introduced.)

   `encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#`
   are structure) and existing pct-triplets, and encodes the rest. A BRACE
   passes through: it can only reach a literal from a malformed template,
   where an unclosed `{` becomes trailing text, and `%7B` would leave a
   string resembling neither the template nor anything the server can match.

   This is also what removed the preview's placeholder machinery. No token
   can survive a round trip now that literals are encoded, so the preview
   assembles part by part instead -- `encodeLiteral` for literal runs,
   `expandTemplateExpression` for filled expressions, `part.source` for the
   rest. Both halves still come from the shared expander, so the preview
   cannot promise a URI that submitting would not send, and the question of
   what token could never collide with a server-supplied template is gone.

2. On the varname set I took the structural point rather than the
   conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a
   separately named `TOLERATED_VARCHAR` tier, and a name needing it parses
   with `conforming: false`, surfaced on `TemplateVariable`.

   So the grammar in the code says what the RFC says and the tolerance is
   explicit and separately tested -- but the Inspector still expands
   `{default-graph-uri}`, because real servers publish hyphenated names and
   the SDK's matcher round-trips them. Refusing to read a resource that
   demonstrably works is a worse failure for a debugging tool than expanding
   a name the conformance suite would reject. A caller wanting RFC-exact
   behavior refuses on the flag rather than re-deriving the grammar.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:44:45 -04:00
cliffhall
4b143ce043 fix(scripts): validate the bin name and that its file exists
Copilot review on #1997:

- A string-form `bin` is npm's shorthand for ONE command, named after the
  package, so ignoring `binName` let a typo silently resolve the package's
  only executable while bypassing the documented "declares no such bin"
  failure. Match it against the manifest's unscoped name instead.
- A declared bin whose file is absent (partial install) was returned
  unchecked. `process.execPath <missing>` still spawns and exits 1 with
  empty stdout, which `rawProjectListing` records as "no diagnostic
  captured" — reproducing the bogus every-file-uncovered report this
  helper exists to eliminate. Throw here, where the remedy is actionable.

Also restores `rawProjectListing`'s docblock, which the new `tscEntry`
block had orphaned above itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:43:24 -04:00
Cliff Hall
e9a581d912
Merge pull request #2038 from modelcontextprotocol/v2/feat/1915-manual-server-headers
fix(web): correct the Custom Headers OAuth hint, name key/value rows for a11y (#2040)
2026-08-16 23:36:32 -04:00
Cliff Hall
2333a98fe7
Merge branch 'v2/main' into v2/fix/1939-typecheck-coverage-windows 2026-08-16 23:23:04 -04:00
Cliff Hall
4d7405bea0
Merge branch 'v2/main' into v2/feat/1915-manual-server-headers 2026-08-16 23:21:17 -04:00
Cliff Hall
1e0fd88956
Merge pull request #2034 from modelcontextprotocol/v2/fix/1860-mrtr-results-panel
fix(web): name an empty tool result instead of "No results yet" (#1860)
2026-08-16 23:20:29 -04:00
cliffhall
ef3f5e74d8 Merge branch 'v2/fix/1919-rfc6570-uri-template-expansion' of https://github.com/modelcontextprotocol/inspector into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 23:18:23 -04:00
cliffhall
52abe8a165 fix: count a pct-triplet as one character when truncating a prefix
Addresses Copilot's round-9 review on #2035.

RFC 6570 2.4.1 counts the prefix in *characters* and is explicit that a
pct-encoded triplet counts as a single one. Truncation went by code point,
so it cut a triplet in half -- measured on 6acaff70:

  x{+v:5}  v = "%61%62%63%64%65%66"  =>  "x%61%256"

The orphaned "%6" is not a triplet, so encodeAllowReserved then (correctly)
escaped its `%`, and the value went out carrying a byte the caller never
asked for. Now "x%61%62%63%64%65".

Triplets are units only under `+` and `#`. Under every other operator the
encoder escapes a `%` anyway (`%61` -> `%2561`), so the three characters
really are three and grouping them would mis-count the prefix -- asserted
both ways. Astral code points remain single units under both, which is what
`Array.from` was there for.

Also corrects the comment on `readResourceFromTemplate`, which still said
the shared helper drops blank values. It has not since 6acaff70: `params`
passes through as given, so only an absent key omits its expression and a
direct API caller can request `?q=` deliberately. A caller whose values come
from a form drops its own blanks first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:18:20 -04:00
Cliff Hall
63250a6c52
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 23:16:27 -04:00
cliffhall
6acaff70ca fix: honor a defined-but-empty value, and announce the expansion error
Addresses Copilot's round-8 review on #2035 (one inline comment, one
suppressed). Both real.

1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole
   expression, from one defined as the empty string, which expands. The
   expander was calling `definedValues` on the way in, collapsing the two --
   so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no
   way for any caller, `readResourceFromTemplate` included, to request
   either that or `x;q`. Key presence is now honored, including §3.2.7's
   rule that the matrix operator drops the `=` for an empty value (`x{;q}`
   -> `x;q`, while `?`/`&` keep it).

   Dropping blanks is a FORM concern, so it moved to the two form
   boundaries: the web panel seeds every declared variable with "" and the
   TUI's ink-form returns "" for every untouched field, so neither can tell
   "never touched" from "deliberately empty" -- but that is a fact about a
   text input, not about the template, and it has no business deciding what
   a template means for every other caller.

   Six unit tests and one integration test encoded the old collapse and now
   assert the corrected meaning. The prototype-collision cases get stronger
   rather than weaker: a blank `{?toString}` expands to `?toString=` -- its
   OWN empty value -- where the original bug expanded the inherited
   function's body.

2. The expansion error is now `role="alert"`. It can appear while typing (a
   pasted value that cannot be encoded), and plain text is not announced, so
   a screen-reader user met a disabled Read Resource with no stated reason.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:08:30 -04:00
cliffhall
4dd8ed4498 revert(web): drop custom headers from the manual add form (#1915)
Server Settings already has a Custom Headers editor, so adding one to the
manual add form created a second place to edit the same field. The reporter
had also conditionally withdrawn #1915 — "editing that information from the
server settings dialog is sufficient" once #1848 closed, which it has.

Reverts the add-form editor, the shared KeyValueRows element, the optional
`settings` argument on addServer / updateServer, and the App-level merge
helper. What remains are two defects found while working in
ServerSettingsForm, which this PR now closes instead (#2040): the Custom
Headers hint misstating OAuth precedence, and the key/value rows' missing
accessible names.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:57:34 -04:00
Cliff Hall
4e4aa49db1
Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides
feat(auth): Auth URL and Token URL overrides in Server Settings
2026-08-16 22:49:19 -04:00
cliffhall
1c24eda429 fix: validate the whole varspec, and derive the TUI's message from its gate
Addresses Copilot's round-7 review on #2035. Two suppressed comments, both
real; each reproduced against this branch before acting.

1. `parseVarSpec` was a "strip the first `*`, trim, split on `:`" pass, so
   it accepted shapes RFC 6570's varspec production does not admit --
   measured on the previous commit:

     x://{*id}     name "id"  =>  "x://abcdef"   explode is a TRAILING modifier
     x://{id*:3}   name "id"  =>  "x://abc"      the two modifiers are exclusive
     x://{ id }    name "id"  =>  "x://abcdef"   whitespace is not a varchar
     x://{a b}     name "a b" =>  "x://1"        ditto -- and it emits into the URI

   `{id*:3}` is the worst of them: it silently truncated the value through a
   combination the grammar forbids. Parsing is now one anchored regex --
   a varname, then at most one trailing `*` or `:1-9999`.

   The name set is `varchar` widened by `-` and `~`, deliberately. A name is
   the one thing expansion emits into the URI WITHOUT encoding it (`;`, `?`
   and `&` write `name=value`), so the rule it has to satisfy is "needs no
   encoding" -- RFC 3986 unreserved plus pct-encoded, of which the RFC's own
   varchar is a subset. Strict varchar would refuse `{user-id}`, which real
   servers publish and which the SDK's matcher round-trips; refusing to read
   a resource that demonstrably works is a worse outcome for a debugging
   tool than a name one character outside the production. Everything now
   rejected is a shape we would otherwise have to guess at.

2. The TUI built its "Missing required template variable(s): ..." list with
   its own bare `values[name]` filter while gating on `hasRequiredValues`,
   which reads own properties only. For a variable legitimately named
   `constructor` the two disagreed: `({})["constructor"]` is `Object`, whose
   `.length` is 1, so the list came out empty and the error named no field
   at all while the submit stayed blocked. Both now come from one exported
   `unmetRequiredGroups`, with `hasRequiredValues` defined in terms of it.

Also renames a test whose title still said multi-name query expressions were
left "to the SDK, which handles them" -- expansion has been owned here since
935113d1; the behavior it asserts is naming, not delegation.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:48:36 -04:00
Cliff Hall
405d0f4d0d
Merge pull request #2039 from modelcontextprotocol/v2/chore/1959-no-floating-promises
chore(lint): enable @typescript-eslint/no-floating-promises in all five ESLint scopes
2026-08-16 22:35:52 -04:00
cliffhall
1ad3edd96d fix: refuse a template that cannot expand, instead of reading the raw one
Ports the two behaviors PR #2033 (the parallel attempt at #1919, now closed
as a duplicate) got right and this branch did not.

1. An expression declaring no variable is a malformed template, not one
   with a member to skip. RFC 6570 requires at least one varspec per
   expression and admits no empty member, so `{}`, `{,}`, `{a,}`, `{?}`
   and `{*}` (an explode modifier is not a name) now make the template
   invalid. Skipping them was the more dangerous reading: `x://{}`
   expanded to `x://` while the form rendered no inputs, so its
   "everything required is filled" check was vacuously true and it
   submitted a URI that is not the template the server published.

2. The read is withheld when expansion fails. `expandUriTemplate`'s
   raw-template fallback exists for the preview, which runs during render
   -- but the submit path used it too, so an invalid template was read
   with its braces intact and the server answered with a confusing "not
   found" for a defect that is not the user's. `tryExpandUriTemplate`
   returns the URI or the reason as a value the caller cannot mistake for
   one; the panel gates Read Resource on it and prints the reason.
   This also covers a value that cannot be encoded -- an unpaired
   surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
   `URIError` on it, and a text input can hold one via paste.

`requiredGroups` now skips an expression naming no variable: an empty
group can never be satisfied, so it would gate the form a second time on
a condition nothing can meet, and the "any one of" hint built from it
would name no fields. The accurate reason is the malformed template,
which the expansion gate reports.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:32:27 -04:00
Cliff Hall
7a0ef43e67
Merge pull request #2036 from modelcontextprotocol/v2/fix/2006-crlf-sse-frames
fix(web): accept CRLF-delimited SSE frames on the servers events stream
2026-08-16 22:28:35 -04:00
cliffhall
51d35d864b fix(web): correct the OAuth header hint, keep utils off components (#1915)
Round-4 review follow-ups on #2038.

The Custom Headers hint claimed a custom `Authorization` value is "ignored"
once OAuth is configured. Verified against the installed
@modelcontextprotocol/client: both SSE and streamable-HTTP `_commonHeaders()`
set the bearer first and then spread `requestInit.headers`, so a custom
value *overrides* the OAuth token. The hint told users the opposite of what
would happen, and could have them silently break their own auth. Corrected in
both places — the modal's new hint and the pre-existing one in
ServerSettingsForm, which carried the same wrong claim.

`utils/serverSettingsPatch.ts` imported both its shared types from
components, reversing the one-way `components -> utils` direction AGENTS.md
sets. `KeyValuePair` moves to `utils/keyValuePairs.ts` (KeyValueRows
re-exports it), and the mode is spelled as a literal union — still checked,
since `ServerConfigModalMode` must stay assignable to it at the call site.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:14:40 -04:00
cliffhall
85e3215534 fix(tui): retire the disconnect token on a server switch, not just clear
Copilot round 3 (PR #2039): clearing the banner when `selectedServer`
changes is not enough. Start disconnecting A, switch A → B → A, and by
the time the original request rejects both guards match again — the
attempt token is unchanged and the selected server is A once more — so
the stale failure lands on the freshly re-selected A. Bump the token on
every switch, so a round trip invalidates prior disconnects the same way
a one-way switch does.

New test drives exactly that interleaving; it fails without the bump
(the one-way test does not, which is why this needed its own).

Also attaches the before/after TUI screenshots the review asked for —
the review was right that "no UI change" stopped being true once the
banner was added — and corrects the PR description accordingly.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:11:40 -04:00
cliffhall
8d6803953c perf(auth): gate the override wrapper on the discovery request, and guard disk values
Two suppressed review findings.

With an override configured, the wrapper cloned and parsed every
successful application/json response — every tools/call result and every
resource payload on the direct CLI/TUI transports — only to conclude it
was not a metadata document. It now tests the request URL for the RFC
8414 / OIDC well-known paths first, so ordinary traffic costs one string
test and is returned untouched.

The read path also trusted StoredMCPServer's `string` type for the two
override fields, which a hand-edited mcp.json does not honor: the CLI and
TUI read the file directly and then `.trim()` these, so a number would
crash the server load. Guarded with cleanEndpointOverride, mirroring
cleanAuthorizationParams.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:11:16 -04:00
cliffhall
1939136eff fix: read template variables as own properties only
Addresses Copilot's round-6 review on #2035.

`toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC
6570 variable names -- `varname` allows ALPHA / DIGIT / `_` / pct-encoded
-- and every lookup here was a bare `values[name]`, which finds
`Object.prototype`'s member for each of them. Measured: `({})["toString"]
!== undefined` is true with typeof "function", and `({}["constructor"] ??
"").length` is 1.

So a *blank* `{?toString}` field read as supplied and expanded a function
body into the URI instead of omitting the expression, and
`hasRequiredValues([["constructor"]], {})` returned true because
`Object.length` is 1 -- a required group satisfied by nothing.

All reads now go through an own-property `readValue`, and the web
preview's filled-check uses `Object.hasOwn`. `expandExpression` carries
the value through its filter rather than re-reading it afterwards, so the
"is it defined" test and the read cannot disagree and nothing needs a
non-null assertion.

The remaining six findings were documentation drifting behind the code,
which matters here because the comments are the argument for why this
module exists:

- The web helper still described the SDK's second-`?`-to-`&` rewrite as
  something the preview relies on. That was removed last round.
- Three places still named the CLI as a consumer. It has no template form
  and passes an already-expanded `--uri` to `readResource`.
- The PR description still described the superseded architecture
  (expansion delegated to the SDK), i.e. the opposite of what now ships.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:02:39 -04:00
cliffhall
d60a5c713d fix(web): only write settings on a real header edit; label rows by position (#1915)
Round-3 review follow-ups on #2038.

`buildHeaderSettingsPatch` treated the mere presence of stored headers as a
change, so saving an id or URL edit re-sent the modal's settings snapshot —
taken when it opened — and could overwrite a metadata or OAuth change made
in the settings form since. It now compares the submitted headers against
the stored ones and omits `settings` when they match, which is what makes
the PUT route preserve the node. Order counts as a difference: it is what
the form round-trips and what the user sees.

Row labels carried the key alone, so two rows sharing a key — mid-edit, or a
duplicate a server persisted — gave both rows' controls identical accessible
names, the thing the labelling exists to prevent. The row number is now
always part of the label rather than a blank-key fallback.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:57:09 -04:00