Commit graph inspector/README.md
Author SHA1 Message Date
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
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
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
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
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
ebc18e1d56 fix: expand each expression independently, and reject invalid varspecs
Addresses Copilot's round-5 review on #2035 — eight suppressed comments,
all of them real.

The consequential one overturns behavior I copied from the SDK and
defended in two earlier rounds. RFC 6570 expands each expression
independently; it carries no cross-expression state. The SDK tracks
whether a query expression has already emitted and rewrites a later
`{?two}`'s `?` to `&`. That looks friendlier and is wrong, and its own
matcher says so: for `x{?one}{?two}` it expands to `x?one=1&two=2`, and
`match("x?one=1&two=2")` on that same template returns **null**, while
`match("x?one=1?two=2")` returns both variables. So the rewrite emitted a
URI the advertised template cannot match — #1919 one level up. A server
that wants a continuation advertises `{?one}{&two}`, which expands to `&`
and matches; producing that shape is the server's choice to declare, not
ours to infer.

Also:

- `{id:abc}` was silently treated as `{id}`, and a test codified it. RFC
  6570's max-length is `%x31-39 0*3DIGIT`, so `{id:}`, `{id:0}`,
  `{id:abc}` and `{id:10000}` are invalid templates rather than templates
  with an ignorable modifier — and the SDK's constructor accepts them all,
  so nothing else rejects them. Strict expansion now throws; lenient
  returns the raw template. Discovery stays lenient so the panel still
  renders rather than going blank.

- The TUI could submit `{a,b}` completely blank while the web panel
  blocked the same request: ink-form has no way to express "any one of
  these", so its members are left optional and the modal needs its own
  group check. Added, with tests — it was also below the coverage gate
  without them (functions 76.92%).

- Added the integration test the config never had: it resolves the
  checked-in config and drives all four reads over a real transport, so a
  misspelt preset fails there rather than only when someone runs the repro
  by hand.

- "All three clients" was false. The CLI has no template form and its
  `resources/read` passes the already-expanded `--uri` straight to
  `readResource`, so nothing here runs for it. Corrected in the README,
  AGENTS.md, and the module comment, and dropped a docblock still
  describing the SDK-delegation path removed last round.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:38:52 -04:00
cliffhall
935113d1e0 fix: encode per RFC 3986, and derive the TUI form from the shared parser
Addresses Copilot's round-4 review on #2035 — again reported as "no new
comments" while carrying three suppressed ones. All three reproduced.

1. The TUI form still named its fields from the SDK. `uriTemplateToForm`
   read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields
   ";id" and `{id:3}` yields "id:3". The form therefore submitted
   `{ ";id": "7" }` while the shared expander looked up `id`, found
   nothing, and dropped the expression — the value vanished silently.

   This is the finding that mattered most, because it falsified the claim
   this change is built on. Moving the expander into core only makes the
   clients agree if each client's FORM derives its names from the same
   parser: a form submits under the names it rendered. The TUI now reads
   `templateVariables`, and the SDK template is constructed only to
   validate, preserving the existing malformed-template diagnostic.

   Its `required` flag comes from `requiredGroups`, and only a variable
   that is the sole member of a non-omittable expression is marked: ink-
   form cannot express "any one of these", so marking every member of
   `{a,b}` required would refuse input the expander accepts.

2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the
   sub-delims !'()* bare, which RFC 6570 requires encoded for every
   operator except + and #.

   Fixing that settled a design question left open in the previous round.
   With two encoders, the SAME value encoded differently depending on
   whether its expression happened to carry a modifier, since only the
   modifier pushed it onto our path. So delegation is gone entirely: one
   expander, one set of rules. The SDK's `UriTemplate` is still
   constructed, but only to validate a template.

3. The "Any one of: a, b" hint could contradict the disabled submit
   button. A name can sit in a singleton required group AND a shared one
   (`x://{a}/{a,b}`), where the singleton demands that exact field. The
   hint is suppressed in that case.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:04:20 -04:00
cliffhall
519ecf49ee chore(lint): enable no-floating-promises in all five ESLint scopes
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.

The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.

Measured surface, and the fixes:

- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
  invokes subscribers with a bare `subscriber()`, so a promise returned
  from the listener is floated one layer up; `closeAbortedStream` owns
  the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
  caller is a key handler that cannot await, so a failing disconnect was
  a live unhandled rejection; it now surfaces the message the way
  `handleConnect` does. The rest are `void` at callers that genuinely
  cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
  returns a promise, now awaited (`scrollAreaStoryAssertions` is async
  for the same reason). Two tests were asserting against a provider call
  they never awaited. `open(url)` in both web servers now warns instead
  of escaping a synchronous callback.
- cli, launcher — already clean.

Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.

Closes #1959

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:57:32 -04:00
cliffhall
364d8c9dbc feat(auth): per-server authorization and token URL overrides
Adds two optional per-server fields — `oauth.authorizationUrl` and
`oauth.tokenUrl` — that override the endpoints authorization-server
metadata discovery resolved, so a server can be pointed at a development
or staging authorization server without changing what it advertises.

SDK v2 routes both endpoints through the discovered metadata document and
neither through OAuthClientProvider, so the override is applied by
wrapping the client's base fetch and patching the metadata document in
flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather
than only the auth fetch — also covers the discovery the SDK runs from
inside the transport on the 401/refresh path.

The fields round-trip through mcp.json, the /api/servers routes, the
runner (CLI/TUI), and the Server Settings → Authorization panel, where a
value that is not an absolute http(s) URL is flagged inline; at runtime
such a value is dropped with a single warning rather than failing the
connection.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:36:24 -04:00
cliffhall
e7d1246c86 fix(core): scope requiredness to expressions, and encode + / # per RFC 6570
Addresses Copilot's round-3 review on #2035. It again reported "no new
comments" while carrying two suppressed ones; both reproduced.

1. Requiredness could not live on a variable at all. Deduplication kept the
   first occurrence's group, so in `x{?a}{?b}{a,b}` both names ended up
   required with singleton groups and the form demanded both -- while the
   SDK expands that template with only `a` to "x?a=11". Widening the stored
   group would not have been enough either: in `{a,b}{a,c}`, filling `b` and
   `c` satisfies both expressions, which no per-variable flag can express.

   So requiredness is now returned per expression by `requiredGroups`, and
   `hasRequiredValues` asks that each group be satisfied by any one of its
   names. `TemplateVariable.groupNames` is gone rather than left as a field
   that quietly means something narrower than it reads; `required` remains,
   documented as driving the "Optional" marker and nothing else.

2. `encodeURI` is not the allow-reserved encoder `+` and `#` call for. It
   escapes `[` and `]`, which are reserved and must survive, and it escapes
   `%`, so an already-encoded value is double-encoded. Measured:
   "[::1]" -> "%5B::1%5D" and "%2F" -> "%252F". Both corrupt the URI rather
   than merely over-escaping it -- an IPv6 literal or a pre-encoded path
   reaches the server altered, which is the same class of defect #1919 is
   about.

   Added an RFC 6570 3.2.1 encoder that preserves reserved characters and
   existing pct-triplets, splitting on `%XX` so a lone `%` is still encoded
   to `%25`, and matching with the `u` flag so an astral character is
   encoded whole. `+` and `#` expressions are now taken over even for a
   single name, so both expansion paths agree on what those operators mean.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 19:35:33 -04:00
cliffhall
25c3ad272e fix(core): support ; and :length, and scope requiredness to the expression
Addresses Copilot's round-2 review on #2035. It reported "no new comments"
but carried three *suppressed* ones; all three reproduced against the
pinned SDK.

1. The `;` path-parameter operator is absent from the SDK's operator list,
   so `{;id}` parsed as a variable literally named ";id" and expanded to
   nothing. Added the operator and its named expansion (`;a=1;b=2`).

2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}`
   yielded a variable called "id:3" and expanded to nothing. Varspecs are
   now parsed properly and the value truncated before encoding. Truncation
   is by code point, since `String.prototype.slice` counts UTF-16 units and
   would split an astral character into a lone surrogate.

   For both of these the wrong URI is the lesser problem: a form has to
   *name* the variables it asks the user to fill, so the panel was
   rendering fields labelled `;id` and `id:3` that nobody could use.

3. Requiredness was applied per variable, but it is a property of the
   *expression*: RFC 6570 drops undefined names from a multi-name
   expression, so `{a,b}` with only `a` filled expands to `a`'s value --
   the SDK does this too. The panel was refusing input the expander would
   have accepted. `hasRequiredValues` now encodes "any one name in a
   required group suffices", and such a field reads "Any one of: a, b"
   rather than falsely claiming each is mandatory.

Two structural consequences, each pinned by a test:

- Takeover is now per TEMPLATE rather than per expression. Splicing
  corrected fragments into a template the SDK re-expands would leave its
  cross-expression `?`-to-`&` rewrite blind to the fragments already
  resolved.

- Expansion is split into a strict variant that throws and a lenient one
  that returns the raw template. `readResourceFromTemplate` wraps the
  thrown error with the template name -- three pre-existing integration
  tests assert that -- while the form must not throw on a server-supplied
  template, since that would take out the panel on render. The strict
  variant constructs the SDK template unconditionally, because that
  construction is what validates syntax: otherwise `x://{;a}{b,c` would
  take the own-expansion path and its unclosed tail would pass as literal
  text with nothing objecting.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 19:17:05 -04:00
cliffhall
409dce37be fix(core): share URI-template expansion and correct the SDK's multi-name branch
Addresses Copilot's review on #2035. All three findings reproduced against
the pinned SDK before acting.

1. `#` was misclassified as required. Measured: `x://a{#frag}` with no
   `frag` expands to exactly `x://a`, a well-formed URI naming a real
   resource -- unlike `{+path}` (`x://a/`) or a simple `{userId}`
   (`file:///users//profile`), which leave an empty path segment. Moved `#`
   into the omittable set; the required cases now assert what the URI
   *becomes* when blank, so the rule is checked rather than asserted.

2. Delegating to the SDK did not actually give RFC 6570 expansion.
   `UriTemplate.expandPart` takes an early `names.length > 1` branch that
   raw-joins values, skipping both `encodeValue` and the operator prefix:
   `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` -- the very
   unencoded-slash defect this PR is about -- and `x://a{/p,q}` to
   `x://ax y,z`. Only `?`/`&` are correct, being dispatched earlier.

   Fixing that in the web client alone would have left the TUI and CLI
   wrong, since `readResourceFromTemplate` expands through the same class.
   So parse/classify/expand now live in `core/mcp/uriTemplate.ts` and both
   call sites use it. The correction is surgical: a multi-name non-query
   expression is expanded here and spliced in as literal text before the
   SDK sees the template (safe -- both encoders escape `{`/`}`), while
   every single-name and query expression still goes through the SDK
   untouched. The preview applies the same correction, so it cannot
   promise a URI that submitting would not send.

3. The showcase promised a blank `{?topic}` read that did not work.
   `UriTemplate.match()` compiles `{?topic}` to a *required*
   `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the
   read 404s. A real server exposes the unfiltered collection as its own
   resource; the showcase now registers `foobar://events` so the
   documented step resolves. Verified end to end through the CLI.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 18:51:12 -04:00
cliffhall
83658db2dd fix(web): expand resource templates per RFC 6570
The Resources screen discovered and substituted template variables with a
bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an
expression carrying an operator, so `foobar://events{?topic}` rendered no
input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`,
space or non-ASCII character in a simple `{topic}` landed unencoded and
changed the URI's structure -- `foo/bar` produced an extra path segment
that a conforming matcher rejects with `-32602 Resource not found`.

Delegate expansion to the SDK's `UriTemplate`, the same implementation
`InspectorClient.readResourceFromTemplate` (and so the TUI) already
expands through, so the two clients cannot disagree about what a template
means. The new `utils/uriTemplate` supplies only what that class does
not: which variables to render an input for, which of them a read cannot
proceed without, and a partially-expanded preview.

Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole
expression is omitted when the variable is undefined, so those fields are
marked Optional and reading with them blank is a legitimate request for
the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI,
so it stays required. Blank fields are dropped before expanding so an
untouched optional field reads as undefined rather than as the empty
string, which would expand to a valueless `?topic=`.

Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json`
showcase server serving the two templates from the issue.

Closes #1919

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 18:31:35 -04:00
cliffhall
a9b9a917e3 chore(scripts): compare only the copies a program actually met
Two findings from the Copilot review of #1965, both real.

1. The candidates were flattened to package names before the version
   comparison, so a third install holding a different copy failed the guard
   even when no program loads that copy beside another — and the diagnostic
   named an install that never took part. `crossInstallPackages` now returns
   the co-occurrences themselves (name → program → install → entry paths) and
   `findSkew` compares per program, across only the installs that met there.

2. Folding a nested copy onto its outermost install lost the path that
   identifies which copy was loaded, and the version was then read from the
   install's top-level entry — absent or differently versioned, either way
   letting a real pair pass. `classifyModulePath` now also returns the
   `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes
   `lockVersionsByPath`, and each holder is priced from the entry the program
   resolved. A resolved copy with no lockfile entry is now a hard failure
   rather than a silently dropped holder.

The failure output prints `in <program>` with each holder's install path
beside its version, so a nested copy is distinguishable from a top-level one.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:35:38 -04:00
cliffhall
7f9be24bf3 chore(scripts): derive dep-lockstep candidates from what enters each tsc program
`verify:dep-lockstep` derived its candidate set from the packages the shared
first-party sources named *directly*, so a package whose declarations reach a
program only through another package's `.d.ts` was invisible to it — it could
skew across installs and recreate the two-copy blow-up #1896 was about while
the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never
written in first-party code, yet 16 of its `.d.ts` files land in clients/web's
test program.

Replace the derivation with the measured one: list every client tsconfig
project with `tsc --listFilesOnly`, map each resolved `node_modules` file to
its owning install and package, and keep the packages that reach ONE program
from TWO installs. That is exactly the set that can put two structurally
distinct copies of a type in front of one checker. The rest of the guard is
unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as
a name allowlist scoped to within-a-major.

The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with
`verify:typecheck-coverage`, so the two guards can't disagree about what a
program contains. It is deliberately not cached to disk between the two
processes: a stale fingerprint would make a guard measure a program that no
longer exists and pass on a real miss. The second pass costs ~14s.

`TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are
root-only since #1970 and cannot skew; `react` (whose types are `@types/react`,
resolved from one install) and `hono` never reach one program from two
installs. Each would be a rationale for a skew that cannot occur.

Closes #1965

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:17:15 -04:00
cliffhall
17a4c78dd3 fix: centralize the nullable-union collapse in core and apply it in the TUI
Closes #2015, folded into #1928 rather than left as a follow-up.

The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.

Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.

- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
  `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
  gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
  `resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
  and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
  encoding uses.

Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 16:59:37 -04:00
cliffhall
937f1dd51b fix: render nullable enum fields as a Select instead of a raw JSON textarea
A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.

`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:

- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
  (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
  as v1.x did. This also collapses nine near-identical blocks into two and
  picks up the `object`/`array` cases the `type: [X, "null"]` form was
  missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
  and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
  back to `null`; a non-nullable enum keeps none, since it would reject it.

Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 16:10:17 -04:00
cliffhall
d0143ef843 docs: tag origin/main explicitly so a release can't point at a local commit
git checkout main && git pull resolves through the maintainer's configured
merge/rebase strategy, so a divergent local main can produce or replay local
commits. Tagging HEAD there tags a commit that is not on origin/main, and
pushing only the tag leaves a release whose commit was never published.

git tag <x> origin/main pins the tag to what the remote branch actually points
at, regardless of local state.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 15:04:34 -04:00
cliffhall
4d567c97e4 docs: make the release commands copy-pasteable and correct the invariant
Copilot review:

- `<x.y.z>` / `<major|minor|patch>` inside a bash block is shell redirection,
  so both documented commands were syntax errors if copied. Verified: `bash -n`
  rejects `git tag <x.y.z> && ...` and accepts `git tag 2.3.0 && ...`. Use a
  concrete 2.2.0 -> 2.3.0 example and say what to substitute.
- "agree on the version at every point" / "never disagree" overstated it. The
  procedure deliberately bumps v2/main first, so between the bump and the
  milestone merge the branches differ — v2/main reads the version being built,
  main the one released. Describe the real invariant: they converge when the
  release merge lands, and v2/main is never left behind main.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:47:33 -04:00
cliffhall
9048e69758 docs: drop the v prefix from the release tag step
This repo's release tags are bare x.y.z (2.2.0, 2.1.0, 2.0.0), not v-prefixed.
Also notes why the manual tag matters: npm's tag-version-prefix defaults to 'v'
and the repo sets no .npmrc, so a bare `npm version` would have produced a tag
that doesn't match the convention.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:39:30 -04:00
cliffhall
a9ed087f25 chore: sync v2/main to 2.2.0 and bump on v2/main before the milestone merge
v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest`
are at 2.2.0. The bump was created on the milestone-merge branch, which is cut
from main, so it only ever existed downstream of v2/main and nothing carried it
back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on
v2/main.

That is not cosmetic. A branch cut from a milestone-merge branch silently
carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff
on a container bugfix — and anything reading the version in development
(readInspectorVersion, --version, GET /api/config) reported a version two
releases old.

Move the bump to v2/main, before the milestone merge, so it flows into main with
the rest of the milestone's work and the branches never disagree. The release
tag is applied to the resulting main commit instead. The publish workflow needs
no change: its tag-vs-package.json assertion runs at the release commit on main,
which still carries the bump.

Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2
v1 history (~230 commits v2/main lacks, retained through `chore: replace main's
tree with v2`), so merging would graft all of it into the develop branch's log
permanently to deliver a two-file change.

Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a
specific version value, so this is a safe two-file change.

Closes #2010

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:16:03 -04:00
cliffhall
adde9ccf94 fix: bind loopback by address and pin the sandbox port so containers work
Two defaults that each break the Inspector inside a container.

#1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted
to the NAME `localhost`, and `server.listen(port, host)` resolves a name
through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17
the default result order is `verbatim`, so on a glibc Linux host — whose
/etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The
server bound IPv6 loopback only and refused every IPv4 client, including the
dev container port forwarder. It presents as an endless spinner rather than a
connection error because the forwarder accepts on the host side before failing
to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit
HOST=localhost is still honored as typed.

#2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox
listened on an OS-assigned port, so it changed every run and could never be
named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default
it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an
OS-assigned port (loudly) if it is taken — the collision a dynamic default
never had.

Verified in the reporter's exact image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners
come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox.

Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both
defaults in the web README, the migration guide, and the root README's Docker
section.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 12:33:27 -04:00
cliffhall
94a227b9c1 fix(tui): widen the root react range so an external ink can't split React
Review follow-up on #1983. Copilot was right, and about a hazard this repo
had already proven and then documented away: the claim that ink's ">=19"
peer "keeps npm from hoisting it next to a React the Inspector couldn't
also use" is false. A consumer pinning React 19.0 satisfies ">=19" while
`^19.2.4` nests underneath, so ink renders through their React and the
bundle through ours — the #1952 crash, one level up, breaking TUI startup
rather than just its forms.

Bundling ink closes it (verified: with a `createRequire` banner for the
inlined CJS, since react-reconciler calls `require("react")` and
signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler
and yoga-layout come along. The root `react` range closes it for free
instead: `^19.0.0` is open to the whole major, so npm can satisfy our
React and a consumer's pinned React 19 with one copy, and an external ink
resolves the same copy the bundle does.

Verified against both consumer trees — the reporter's (React 18 + ink 5)
and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`.

That makes the range load-bearing while looking like an ordinary version
bump, so the tests pin it: the root range must equal ink's own declared
peer floor. They also assert each exempt package is both external and a
root dependency — external means consumers install it, the mirror image
of the inlined ones, which must not be root-declared.

The docs in all four places now say ink is exempt on cost and never on a
peer range, and name that claim as one we got wrong.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-12 00:21:43 -04:00
cliffhall
eb5bba62ed fix(tui): bundle React-rendering deps so a consumer install can't split React
`ink-form` and `ink-scroll-view` were externalized by the TUI bundler and
both declare a loose `react` peer (">=18"). Installed under a project that
has its own React 18, npm hoists them to that project's root — where the
peer is satisfied — while the Inspector's React 19 nests underneath. The
bundle then renders through React 19 while those two call hooks on React
18, whose dispatcher is null, so opening a tool test form (or any scroll
view) dies with "Cannot read properties of null (reading 'useState')".

Inline them instead: their `import "react"` is emitted into the bundle and
so resolves from the build directory exactly like the bundle's own, with
npm's placement no longer part of the decision. This also pins their
transitive deps to what this repo resolved — notably `ink-select-input@6`
via `overrides`, which npm ignores for a package installed as a dependency,
so consumers were getting the React-18-era v5.

`ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails
ESM interop with `Dynamic require of "assert"`) and doesn't need to be —
its ">=19" peer keeps npm from hoisting it next to an unusable React.

Both packages are dropped from the root `dependencies`, since the tarball
now ships their code rather than having consumers install them.

`clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the
failure is invisible in this repo and in every smoke (a dev install has
one React) and only appears once the package is installed under another
project, so the invariant is asserted against the config instead — every
dependency declaring a `react` peer must be bundled unless exempted.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-11 23:00:01 -04:00
cliffhall
0a2b7f7179 Merge branch 'v2/main' into v2/docs/1822-migration-guide
Took v2/main's project-layout line and quality-gate table (adding
verify:dep-lockstep), keeping this branch's migration-guide mention and
prettier table alignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 11:18:35 -04:00
cliffhall
e5b32effa7 Merge branch 'v2/main' into v2/docs/1822-migration-guide
README: kept v2/main's showcase table including the new structured-output
row. AGENTS: kept v2/main's newer type-label and triage rules, rendered in
this branch's markdown formatting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 11:15:44 -04:00
Cliff Hall
59f5b6d25c
Merge pull request #1971 from modelcontextprotocol/v2/chore/root-only-mcp-sdk-deps
chore: declare the MCP SDK packages only at the repo root (#1970)
2026-08-11 11:12:02 -04:00
Cliff Hall
16b9eff8f4
Merge pull request #1962 from modelcontextprotocol/v2/chore/zod-version-lockstep
chore(deps): align zod across installs and guard against version skew
2026-08-11 11:09:52 -04:00
cliffhall
0f44288c26 Merge branch 'v2/main' into v2/fix/tool-row-keys
Both sides added a showcase config row and section after the pagination
entry; kept both, structured output first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 11:03:49 -04:00
Cliff Hall
e7f0dc82b3
Merge pull request #1974 from modelcontextprotocol/v2/docs/1964-docker-loopback
fix(docker): publish on loopback by default; make a mounted state dir writable
2026-08-11 10:54:14 -04:00
cliffhall
5be2b3e87b docs: address Copilot review — volume upgrade path, and drop the token claim
- A named volume created by an image predating the ownership fix keeps its
  root ownership if it is non-empty, since Docker only applies the image
  directory's ownership to an *empty* volume. Verified both halves: an empty
  pre-existing volume repairs itself on the first run of the fixed image,
  while one holding a file stays root-owned and still EACCESes. Document the
  distinction and the one-shot `chown` repair (verified to restore writes).

- "pair it with a known MCP_INSPECTOR_API_TOKEN" was bad advice for a wider
  publication: the same paragraph notes `GET /` discloses the token, so a
  custom one is harvested exactly as a generated one is. Point at a real
  access-control boundary instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a
2026-08-11 10:15:28 -04:00
cliffhall
5a3a3436ea docs: correct the dependency-classification claims
Copilot review round 2, four findings, all factual errors in the docs added by
the previous commit:

- `yaml` is in the root `dependencies`, not `devDependencies`, and its only
  importer is `test-servers/src/load-config.ts` — not `core/` or the root
  tooling, as the `vitest.shared.mts` comment claimed.
- The rule as written told a reader to put anything reached through root-owned
  code — `core/` included — in `devDependencies`. That is wrong and would break
  the published package silently: the client builds externalize npm packages, so
  a published install resolves them from the root manifest, where
  devDependencies are absent. Placement (which manifest) and classification
  (which section) are now stated as separate questions, the second following
  from who consumes the package at runtime.

`yaml`'s runtime classification is left alone and called out instead: moving it
changes what ships, which is not a docs change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 09:35:04 -04:00
cliffhall
3173b264f7 fix(docker): publish on loopback by default; make a mounted state dir writable
Closes #1964.

- README's Docker recipes dropped v1's `127.0.0.1:` prefix, so
  `-p 6274:6274` published the Inspector on every host interface. The
  container's `HOST=0.0.0.0` governs the container's interfaces, not the
  host's, so `DANGEROUSLY_BIND_ALL_INTERFACES` never covered this. Verified
  against a real container: from a LAN peer, `GET /` served the injected
  `MCP_INSPECTOR_API_TOKEN`, and that token then unlocked `/api/*` with no
  `Origin` header (server.ts:241 allows origin-less requests). Restore the
  prefix on both recipes, on the port-remap examples, and in the web README.

- Adding a server failed inside the container the moment you mounted a
  volume to keep it. Docker seeds a named volume's ownership from the
  image's directory at the mount point, and creates it `root:root` when
  that directory is absent — so the non-root `node` user got
  `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. Create the
  dir in the image, owned by `node`, and document the volume recipe (plus
  the bind-mount `--user` caveat) — without a volume the catalog lives in
  the writable layer and `--rm` throws it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a
2026-08-11 09:34:41 -04:00
cliffhall
e132d551e6 docs: record where a dependency is declared, and why
Per the maintenance rules, a change to the dependency layout has to land in the
docs with it. Adds the placement rule in three places, at three altitudes:

- README (Setup): the rule plus the concrete drift it prevents.
- AGENTS.md: a "Dependency placement" section — the MCP SDK packages are root
  only, the v1 SDK is not ours and must not become a dependency, and anything
  reached solely through root-owned code with no manifest (`test-servers/src`,
  `core/`) is a root devDependency aliased to the repo root in
  `vitest.shared.mts`.
- .github/copilot-instructions.md: the distilled, reviewer-citable form, so a
  diff re-adding one of these to a client manifest is flagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 09:26:17 -04:00
cliffhall
6f47eaa143 fix: render a tool result's structuredContent in the Tools screen (#1908)
A tool declaring an `outputSchema` returns its real payload in
`structuredContent`; the `content[]` blocks usually only summarize it. v1
rendered that payload as its own inspectable JSON section — v2's
ToolResultPanel only ever walked `result.content`, so the payload was
dropped with no label, section, or hint that it existed.

Adds a `StructuredOutputPanel` group: a collapsible "Structured Output"
box (bordered like the existing "Resource Links" box) rendering the
payload as pretty-printed, syntax-highlighted, copyable JSON, bounded so
a large payload scrolls within itself. ToolResultPanel renders it below
the content blocks — and also when `content` is empty (where "No results
yet" was simply wrong) and below the alert on an error result, so it is
never silently dropped.

Adds a `list_items` test-server preset (nested payload, the issue's repro
shape) and a `structured-output-http.json` showcase config, documented in
the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-11 09:23:43 -04:00
cliffhall
2f2d545751 docs: narrow the dep-lockstep summaries to the invariant it actually proves
From Copilot's ninth pass of #1962. The one-line descriptions in AGENTS.md and
the README claimed "no install-crossing dependency resolves to two different
versions", which is stronger than the gate: candidates cover only packages the
shared sources name directly, and #1965 records a live counterexample
(`@modelcontextprotocol/sdk`, skewed across two install roots while the guard
stays green).

The boundary was already documented at length on the guard and in the AGENTS.md
rule; the summaries just hadn't been narrowed to match, which is how a doc ends
up asserting a guarantee nobody implemented. Both now say "directly imported"
and point at #1965.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 21:48:13 -04:00
cliffhall
2dcf1450b2 chore(deps): see imports through comment trivia; name vitest.shared.mts in the README
Both suppressed comments from Copilot's fourth pass of #1962.

TypeScript allows a comment anywhere whitespace is legal, so
`import(/* webpackIgnore: true */ "pkg")` and `from /* why */ "pkg"` are
valid imports the patterns stepped over. That is the dangerous direction: a
missed specifier never enters the candidate set, so the package's skew passes
the guard silently, against the over-approximation the module claims. A
`TRIVIA` fragment now stands in for `\s*` at every inter-token position, with
regression cases for the commented static, dynamic, side-effect, and
`require` forms.

I did not move to the TypeScript parser. This is a root `scripts/` `.mjs`
tool with no bundler and no TS dependency; pulling in a compiler API to read
import specifiers is a lot of machinery for a scan that only needs to
over-approximate, and each missed form is a one-line pattern with a test
beside it. The derived set is unchanged at 18 real names, so nothing here
widens what is checked today.

The README's command row also still described the candidate scan as `core/`
and `test-servers/src` only, omitting the `vitest.shared.mts` the guard now
includes; it also never mentioned that an allowlisted package is tolerated
only within a major. Both stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 21:07:37 -04:00
cliffhall
e480fdc49d chore(deps): cover .mts/.cts in the shared-source scan, fix the README validate row
Both from Copilot's review of #1962.

The candidate-set enumeration matched only `.ts`/`.tsx`, while
`verify:format-coverage` and `verify:typecheck-coverage` both gate all four
TypeScript extensions. No `.mts`/`.cts` exists under `core/` or
`test-servers/src` today, which is exactly why the omission would have gone
unnoticed until a shared dependency arrived through one and skewed — the
failure this guard exists to prevent. Extract the rule as the exported
`isSharedSourceFile` predicate, cover all four extensions, and pin it with
regression tests (including path-boundary anchoring, so a `core-internal/`
sibling isn't swept in).

The README's `validate` row also omitted `test:scripts` and claimed typecheck
was cli/tui only; launcher runs one too, and web typechecks via `tsc -b`
inside its `build`. Restate the row to match the actual script chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 20:38:41 -04:00
cliffhall
6c33da38c9 chore(deps): align zod across installs and guard against version skew
Closes #1896

`clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The
cause is not a zod regression nor a pathological inference site of ours: it
is two copies of zod at *mismatched versions* inside one tsc program.

`clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files
resolve zod (and the SDK's zod) from the **root** install, alongside web's
own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs
4.4.3 TypeScript must relate two structurally-distinct declarations of every
`@modelcontextprotocol/*` schema type, which is exponential over a recursive
generic surface — `TS2589 Type instantiation is excessively deep`, then OOM.

Bumping root zod to 4.4.3 and changing nothing else returns the build to
baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at
4.3.6. Two copies at the same version are harmless; skew is what explodes.

So drop the `~4.3.6` hold on `clients/web` and move all four manifests and
lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate`
to keep them there. The guard derives its candidate set from what `core/`
and `test-servers/src` import, compares the committed lockfiles' top-level
entries, and fails deny-by-default on any skew not in the annotated
`TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so
dropping it from `validate` is caught by `verify:format-coverage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 20:29:58 -04:00
cliffhall
4340283f70 test: add a duplicate-tool-names test server for the #1957 repro
Nothing in the repo could serve the shape this fix is about: every preset
registers a unique name, and the SDK's `registerTool` rejects a repeat. So the
only reproduction was a unit test — there was no way to smoke the real app
against a server that returns duplicates.

Adds `ServerConfig.duplicateToolNames` (a list of registered tool names) to the
composable test server, plumbed through the JSON config, and a showcase config
`duplicate-tool-names-http.json`. The named tools are emitted a second time in
`tools/list` with the same `name` and a "(duplicate)" title.

The repeats are appended after the whole list rather than placed beside their
twin. That is both the realistic shape (two tool sources concatenated) and what
makes the defect observable: React matches a leading run of same-key children
first, so a head-adjacent duplicate happens to line up and the bug hides. Only
a separated pair collides in the keyed map and orphans a row — verified both
ways against the prod bundle.

The `tools/list` override is shared with the existing pagination handler (which
already hand-builds the list); with no `maxPageSize` it returns a single
unbounded page. Duplication happens before pagination, so a duplicated pair can
straddle a page boundary the way a real server's would.

Also adds a ToolControls test for this appended ordering, distinct from the
reporter's interleaved fixture, and documents the config in the README showcase
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-10 20:22:47 -04:00
cliffhall
2e89443739 docs: add the v1 → v2 migration guide
Closes #1822

Adds docs/v1-to-v2-migration.md — the map for users arriving from v1:

- CLI flag mapping (every v1 flag survives; lists what v2 adds)
- `--config` vs `--catalog` semantics with before/after examples
- Node engine bump (>=22.7.5 → >=22.19.0)
- what no longer ships (the three sub-packages; v2 is one tarball)
- the three behavior changes most likely to bite: exit code 5 on a
  failing tool call, target-must-come-first under --cli, and the
  reversed `--` separator
- env-var mapping (MCP_PROXY_AUTH_TOKEN → MCP_INSPECTOR_API_TOKEN,
  MCP_PROXY_FULL_ADDRESS removed, SERVER_PORT repurposed), the v1 UI
  config settings, web query params, and Docker

Linked from the root README (banner + docs list), the CLI README, and
docs/mcp-server-configuration.md; AGENTS.md's docs/ tree entry updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-10 19:23:43 -04:00
cliffhall
3f4f68699e fix: give smoke:web:app its own port; note the App config's era in the table
Copilot review on #1934, both comments actionable:

- smoke:web:app defaulted to 6299 — the same port smoke:web uses — while its
  own comment claimed the port was "distinct … so back-to-back runs can't
  collide". The three web smokes run sequentially in `npm run smoke`, so it
  passed, but a slow teardown, a TIME_WAIT socket, or a parallel run would
  EADDRINUSE it. Moved to 6297 (smoke:web 6299, smoke:web:browser 6298) and
  rewrote the comment to name the actual values rather than assert distinctness.

- The showcase table tells readers to connect with Protocol Era = Modern unless
  noted, and mcp-app-http.json is legacy-only. That was stated in the prose
  below but not in the row, so a reader scanning the table would try Modern
  first. Marked the row.

Verified: smoke:web:app passes standalone on 6297, and the full `npm run smoke`
chain passes with all three web smokes on distinct ports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
2026-08-06 09:30:39 -04:00
cliffhall
cf0ed4ac78 test: add smoke:web:app — drive a real MCP App end to end in headless Chromium
`smoke:web:browser` stops at first paint and never connects to a server, so
everything downstream of the connect — the Apps tab, the sandbox controller,
the UI-protocol bridge — was unexercised by any smoke. That is the code #1859
broke, and nothing would have caught a regression in it.

smoke:web:app boots the same prod --web server, spawns the mcp-app-http.json
composable server, and drives connect → open app → widget ready through one
deep-link navigate. The assertion is the documented data-app-status="ready"
contract, which the renderer only reports once the widget has loaded inside the
sandbox iframe AND completed its bridge handshake — so a single attribute
covers the proxy being served, the UI resource loading, and the handshake.

Two mechanics found by running it, both silent-failure shaped:
  - server-composable.ts announces readiness on stderr, not stdout, so watching
    stdout alone times out with an empty diagnostic. Both streams are scanned.
  - the bound port is not the configured one — createTestServerHttp resolves via
    findAvailablePort(), which walks upward when the port is taken. The smoke
    parses the announced URL instead of assuming 3130.

Scope: this runs against the repo build tree like every other smoke, so it would
NOT have caught #1859 itself (a packaging failure; the file is always present
in-repo). pack:verify owns that dimension — the two are complements, and the
header comment says so. A cheap structural pre-check does assert the proxy page
sits where sandbox-controller.ts resolves it, so a move/rename fails fast.

Both failure modes verified by hand: with clients/web/static removed, the
pre-check fires; with the pre-check bypassed, the data-app-status assertion
catches it independently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
2026-08-05 19:00:29 -04:00
cliffhall
28a21c5e08 test: add an MCP App showcase config to reproduce and verify #1859
The `mcp_app_demo` tool and `mcp_app_demo_widget` UI resource presets already
existed in the preset registry, but no config wired them into a server — so
there was no runnable way to reach a rendered MCP App, and therefore no way to
exercise the sandbox proxy path that #1859 broke.

`mcp-app-http.json` composes the two over plain streamable-HTTP. With it, the
Apps tab renders a real widget and the packaging bug is reproducible on demand:
without clients/web/static in the tarball the widget area shows "Sandbox not
loaded: ENOENT … /clients/web/static/sandbox_proxy.html" instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
2026-08-05 17:59:09 -04:00
cliffhall
22da48c652 fix: ship clients/web/static so the MCP Apps sandbox proxy loads
`clients/web/static/sandbox_proxy.html` is a committed source file, not a
build artifact, and it was never listed in the root package.json "files"
allowlist. `sandbox-controller.ts` reads it at runtime as
`<runner dir>/../static/sandbox_proxy.html`, so it resolved fine in-repo but
was absent from every published tarball — the read threw and the controller
served its "Sandbox not loaded" fallback page, breaking the Apps tab for
anyone running `npx @modelcontextprotocol/inspector`.

v1 shipped the equivalent as `server/static` in both the root and the server
workspace "files" lists; that entry has no counterpart in v2.

Add `clients/web/static` to the allowlist, and assert it in pack:verify twice
over — once in the tarball packlist and once on disk after install, since what
the runtime needs is the path *relative to* clients/web/build rather than mere
presence in the tarball.

No .npmignore change is needed: clients/web/.gitignore does not list `static`,
so the nested-gitignore packlist hazard that hid `build/` does not apply here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
2026-08-05 17:46:10 -04:00
Cliff Hall
2c24b82e8f
docs: converge README Test servers section with main's restructure (#1880)
* docs: converge README Test servers section with main's restructure

Transplants main's restructured "Test servers" section (#1836) onto
v2/main — the "Serving the modern protocol era" and "Showcase configs"
headings, the config table, and the per-config subsections — replacing
the eight long paragraphs v2/main still carried.

Folds in v2/main's #1846 correction: main's copy still claims
Mcp-Param-* mirroring is skipped in the browser, which stopped being
true when the Inspector took over building the mirrored headers itself.
The blockquote callout keeps main's shape with the corrected wording.

Scoped to that section only — the whole file is deliberately NOT copied
from main, so #1866's repo-status callout and release-section edits (and
every other v2/main-only line) are untouched.

Shrinks the first v2/main -> main milestone merge from a whole-section
conflict to a single 3-line hunk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: point the #1846 reference at the issue, not a nonexistent PR URL

#1846 is the issue; the fix shipped as PR #1847. The callout's link used
/pull/1846, which 404s. Per Copilot review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:01:19 -04:00
Cliff Hall
3ac34e4092
docs: post-swap branch model, PR policy, milestone rule, and Copilot review instructions (#1866)
* docs: correct post-swap branch model, PR policy, and stale gate/tree entries

Part of the go-live phase-8 docs hygiene: everything that silently inverted
when `main` became v2.

Branch/release model — documented canonically in AGENTS.md and corrected
everywhere it was restated:
- AGENTS.md: replace the one-line "Base Branches" bullet with a table giving
  each branch its role (v2/main = develop, main = release, v1/main =
  maintenance), whether PRs target it, and which npm dist-tag it publishes.
- README.md: the "Repo status" callout had it backwards, claiming `main` was
  the legacy v1 implementation. Also note that a v2 release is cut from `main`
  after the milestone merge, not from v2/main.
- CONTRIBUTORS.md: the version table listed v2's base branch as `main`.

PR policy:
- Every PR must reference an issue, from anyone, no exceptions.
- PRs are opened by repo maintainers only — org write access is not
  authorization. Everyone else files a detailed issue.
- Branch names start with the target version segment (v2/…, v1/…).
- Issues and PRs carry exactly one of `v1`/`v2` at creation; default `v2`.
- Screenshots proving UI/TUI changes go in the gitignored pr-screenshots/.

Corrections to stale content found while in there:
- The TUI coverage bullet still described the Ink/App.tsx/hooks exclusion as
  interim pending #1501, which closed 2026-06-29; the gate now covers all of
  src/** with only tui-servers.ts excluded.
- The completion checklist said `npm run validate` was the gate and covered
  "e2e tests", contradicting the mandatory-pre-push-gate section (npm run ci).
- v1 PR base was given as `main`; it is `v1/main`.
- Project tree omitted core/client, docs/, and scripts/ (README: core/client,
  docs/). core/client is under the coverage gate and imported by both App.tsx
  trees.

Also adds the launcher and `npm run test:scripts` to the "run the tests" list
and a tiers overview, since neither was mentioned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: require a milestone on every new issue, defaulting to the current one

Adds the rule alongside the version-label rule, since they fail the same way:
an unmilestoned issue drops out of release planning as silently as an
unlabeled one drops out of version filtering.

- Set it at create time (`gh issue create --milestone <title> ...`).
- If the user didn't specify one, default to the current milestone — the open
  milestone with the nearest due date — rather than leaving it blank pending a
  decision. Includes the `gh api .../milestones` one-liner that identifies it.
- Milestones are release buckets, so pick by when the work ships, not by size;
  sub-issues normally inherit their parent's milestone.

Also widens the section's opening callout: an issue is not "created" until it
is labeled, milestoned, AND boarded with a Status — four distinct steps, not
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: add .github/copilot-instructions.md and require mirroring into it

GitHub Copilot reads .github/copilot-instructions.md when reviewing a PR. The
repo had no such file, so Copilot reviewed without any of the conventions that
actually govern this codebase — the ban on `any` and on unjustified double
casts, the Mantine-first styling rules, the `.withProps()` extraction rule, the
lib/utils split, test placement, and the per-file >=90% four-dimension gate.

The new file is a review-focused distillation of AGENTS.md: the rules a
reviewer would cite against a diff, plus a short "what to prioritize" list that
puts correctness and security first (this backend spawns processes and proxies
outbound requests). Deliberately omitted: board recipes and IDs, milestone and
branch mechanics, release procedure, and the project tree — no reviewer cites
those, and copying them would double the maintenance surface.

AGENTS.md's "Keep documentation files up to date" section now requires
mirroring review-relevant changes into it in the same PR, states that AGENTS.md
stays the source of truth, and spells out what counts as review-relevant. There
is no generation step and nothing detects drift, so the rule is the only thing
keeping the two in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: address Copilot review — v1 policy wording, stale V2 Go Live, typo

- Align the v1 maintenance policy on "security and bug fixes only" in
  AGENTS.md and CONTRIBUTORS.md; both said "security fixes only" while the
  README callout, the branch table, and the board description said
  "security and bug fixes".
- Drop the stale "or V2 Go Live" instruction from the work-begins step —
  that column no longer exists on board #28 (verified via
  `gh project field-list 28`), so the option id it implied would be
  rejected. Restore the removed-column id list the same edit dropped, now
  including V2 Go Live, and re-date the table to today's verification.
- Fix "contains the the new version" typo.
- Strip a stray trailing blank line at the end of AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: restore `inspector/` as the Project Structure tree root

The tree is a filesystem layout — every child is a real directory
(`clients/`, `core/`, `test-servers/`). Labeling its root `v2/main/` named
a branch instead, which reads as a path that does not exist in a checkout
and diverged from the identical tree in README.md, which still says
`inspector/`. The branch model is documented in its own table under
Repository & Project Boards; the tree does not need to restate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: settle the v1 policy wording as "security fixes only"

Reverses the direction the Copilot round took in 33cc0366. That commit
normalized the six v1-policy sites onto "security and bug fixes" because that
was the majority wording — but majority is the wrong tiebreak here. The policy
is set by what we told contributors: the #1819 backlog close note, posted to
120 closed PRs, says "v1 will receive security fixes only", and the five PRs
retained for 1.0.5 are all security fixes.

So the two sites that already said "security fixes only" were the correct ones,
and the four that said otherwise are now aligned to them:

- AGENTS.md: the `v1` label description, the branch-role table, and the v1
  board line.
- README.md: the "Repo status" callout.

CONTRIBUTORS.md and AGENTS.md's Project Status bullet already read correctly
and are unchanged.

This is the substance of #1813, which is why that issue's PR is now reduced to
its PR-template-link half — the wording work landed here to avoid two PRs
editing the same lines in opposite directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

* docs: point the AGENTS.md policy link at CONTRIBUTING.md

This PR rewrites the paragraph holding the only in-repo link to the
contributing policy, so it owns that line. #1884 renames the file
CONTRIBUTORS.md -> CONTRIBUTING.md; having #1884 also edit this line would
guarantee a one-line conflict between the two.

Writing the new filename here instead makes #1884 a pure `git mv` with no
overlapping line, so neither PR conflicts with the other.

Between this merging and #1884 merging, the link points at a filename that
doesn't exist yet. That window is deliberate and bounded — this PR is first in
the merge order (#1821) and #1884 is third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:00:24 -04:00
Sam Morrow
5d91d4acd0
fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1847)
* fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846)

A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server)
rejects a tools/call whose argument carries an `x-mcp-header` annotation
when the matching `Mcp-Param-*` request header is missing (-32020
"header mismatch"). The Inspector never sent that header.

The SDK only mirrors inside `client.callTool()` (and skips it entirely in
a browser environment), but the Inspector routes tools/call through
`client.request()` for manual MRTR driving (#1704), so the SDK never
mirrors for us — on any transport. On top of that, the remote (web)
transport dropped per-request headers before they reached the backend's
upstream fetch.

- core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders`
  + value encoding (Base64 sentinel for non-ASCII/whitespace/empty values)
  and add `mcpParamHeadersForTool`.
- InspectorClient.attemptToolCall: on a modern connection, build the
  headers from the tool's declarations + call arguments and attach them to
  the tools/call request options. `Protocol.request` forwards `headers`
  to the transport and preserves them across MRTR retry legs, so the
  direct StreamableHTTP transport applies them. Fixes CLI/TUI.
- RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest:
  forward per-send headers and apply them to the backend's upstream
  transport.send. The browser can't set cross-origin headers, but the
  Node backend can — where the Inspector's real request is issued. Fixes web.

Verified end-to-end against the live GitHub MCP Server (modern HTTP).

Closes #1846

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: harden backend header allowlist, fix breadcrumbs, trim comments

Address Copilot review on #1847:
- server.ts: restrict client-supplied per-send headers to the `Mcp-Param-`
  prefix (string values only) via `mcpParamHeadersOnly` before applying them
  upstream, so the per-send channel can't inject arbitrary headers (e.g.
  Authorization). `settings.headers` remains the channel for configured
  upstream headers. Covered by new unit tests.
- Fix stale `#1810` breadcrumbs → `#1846` in the two new tests.
- Trim the verbose mirroring comments in inspectorClient/types/remote transport.
- Drop the redundant base64-on-the-wire integration test (encoding is unit-
  tested; wire delivery is proven by the verbatim test), remove two unneeded
  test casts, and cover the boolean `true` branch.

Not changed (declined with reason on the PR): buildMcpParamHeaders remains a
faithful port of the SDK's helper and does not re-validate values against the
declared JSON Schema type — arg types are enforced by schema validation on
both sides, and matching the SDK's conforming wire output is the module's goal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: mirror Mcp-Param-* on the task-augmented tools/call too

Address the maintainer review on #1847:

- `callToolStream` (the "Run as task" path) built its own request options and
  never mirrored, so a task-augmented `tools/call` on an annotated tool was
  still rejected by a strict modern server with -32020. It now mirrors like the
  plain path.
- Extract the two blocks both `tools/call` entry points had duplicated —
  `convertStringToolArgs` (the string-arg coercion) and
  `applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't
  drift again. That duplication is why the task path was missed.
- Regression test: `callToolStream` against the modern `get_weather` server
  asserts the POST carries `Mcp-Param-City` (verified failing without the fix).
- README: the modern-network showcase claimed mirroring is skipped in the
  browser so the web client gets -32020, and that CLI/TUI mirror. Both are now
  wrong — the Inspector mirrors itself, on every client. AGENTS: note that
  xMcpHeader.ts now also builds the wire headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

* docs: correct the stale "browser omits Mcp-Param-*" copy

Both statements predate this PR and are now the opposite of true, per the
wire capture on #1847 (proxy-observed upstream headers, web leg included).

- ToolDetailPanel: the mirrored-headers note claimed the browser omits
  `Mcp-Param-*`. It now says the web client's headers are applied by the Node
  backend that issues the upstream request.
- createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated
  tool is "uncallable from the web client against a strict modern server" —
  the exact wrong conclusion to reach while debugging a -32020. It now records
  that the Inspector builds the headers itself, and that they do NOT travel
  this `/api/fetch` proxy path: they ride the transport's per-send `headers`
  through `/api/mcp/send`.

Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx
matches the section title only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
2026-07-28 19:19:48 -04:00