- `const` constrains a property that is PRESENT; it neither requires the
property nor acts as a default. It is now supplied automatically only where
the schema also requires the field — in `collectSchemaDefaults`,
`applySchemaConstants` and the TUI's initial values alike — so an optional
`dryRun: { const: true }` no longer turns a valid `{}` call into one that
asks the server to do something. A value that IS present is still corrected
to the constant, whatever supplied it, and the field stays read-only.
- Offerability is judged on the base+member merge rather than the raw member,
so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over
root-declared properties is offered. It was declined, which left the gate
checking the base alone and accepting `{}` — which that schema rejects.
- The TUI's branch sections keep the schema's own `required` for the seeding
decision while still rendering every branch field non-required, which is what
keeps a static form submittable.
Signed-off-by: cliffhall <cliff@futurescale.com>
The README claimed both showcase tools rendered nothing but the Execute Tool
button, which the PR's own screenshots contradict: `echo` carries a root
`message` and rendered that one field, so it was callable with half its
arguments; only `get_weather`, whose fields live entirely on its `oneOf`,
rendered no controls at all. The two now show the two halves deliberately.
Signed-off-by: cliffhall <cliff@futurescale.com>
A distinct `const` does not make `oneOf` alternatives mutually exclusive
unless the property carrying it is required: two branches pinning an OPTIONAL
`kind` both match `{}`, so arguments omitting it satisfy more than one
alternative — exactly what `oneOf` forbids. The property must now be required
by the root or by every branch.
Also makes `propertiesOf` total (an unreadable `properties` reads as empty,
with a separate predicate for the one caller that must tell them apart), which
removes a row of unreachable `?? {}` fallbacks, and covers the value/type
comparisons the const checks rest on.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a `oneOf` demands that EXACTLY one alternative match, which
flattening cannot preserve, so it is offered only when a discriminator makes
the alternatives mutually exclusive; `anyOf` makes no such claim and is
unaffected. An empty `additionalProperties` schema is the equivalent of
`true` and no longer declines a union.
- selectBranchIndex: falls back to the branch whose own required fields the
values supply, so an undiscriminated union's values open the picker on the
shape they describe rather than on the first branch.
- admitsNull: honors a sibling `nullable: true` beside `const: null`.
- SchemaForm: a value is carried only where the incoming branch leaves the
root's declaration as it found it; anything it declares itself is reset,
since it may type the name differently from where the value was typed.
`hasOwn`/`fromEntries` throughout, so `constructor` is not read as a
supplied value and `__proto__` is not dropped into the prototype setter.
- TUI: the same prototype-safe construction on the way out of the form, and
the chosen branch's `required` list is checked at submit — rendering a
branch's fields optional makes the FORM satisfiable, not the call, so the
missing names are reported rather than a known-invalid call being sent.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: decline a union that adds fields under a restrictive root
`additionalProperties`. The keyword constrains what its SIBLING
`properties` does not name, so the original schema admits none of the
branch fields; flattening moves them beside the keyword, where they read as
allowed and the form would submit what the schema forbids.
- `default` joins the annotation set: it constrains nothing, so two
declarations suggesting different initial values no longer conflict.
- Property records are read with `Object.hasOwn` and built with
`fromEntries`/`defineProperty`. `properties` is a JSON record, so
`constructor` is a legal argument name that `in` reported as a collision
the root never made, and `__proto__` is one that assignment would drop
into the legacy prototype setter instead of keeping as a field.
- SchemaForm: a value survives a branch switch unless BOTH branches declare
the name. A root argument the outgoing branch merely specialized is still a
root argument in a branch that inherits it, so dropping it erased a valid
value.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: union members get the same flattenability test as allOf members,
so a branch whose nested `allOf: [false]` makes it unsatisfiable is no
longer offered as a callable alternative with that constraint erased.
- selectBranchIndex: weigh only the pinned constants the caller SUPPLIED. One
it did not supply is a value this identification exists to seed, so
requiring it meant a deep link naming `kind` alone matched no branch
whenever the branches also pinned, say, a `version`. A supplied constant
that disagrees is still evidence against.
- admitsNull: `const: null` is conjunctive with its siblings, not an
override — `{ type: "string", const: null }` and
`{ const: null, anyOf: [...] }` reject every value, so the claim is made
only where the type and a sibling union leave null on the table.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a branch restating a constraint the root states differently is
declined (keywords at one level are conjunctive and no intersection is
computed here), while annotations may disagree freely. An `allOf` with a
member it cannot fold in — JSON Schema's boolean form, or a $ref whose
constraints are unknown rather than absent — is left intact instead of
merged-and-stripped, which would turn an unsatisfiable schema into a
fillable form.
- rootUnion: `declaresAnyFields` counts the raw composition members, so
`hasInputFields` reports fields for a union the resolver declines and an
App tool is never auto-invoked with empty arguments.
- SchemaForm: the picker opens on the branch the supplied values identify
(an App deep link overlays its args, so they can name another branch), and
a branch switch no longer carries a value the outgoing branch declared —
the branches may type the same name differently.
- TUI: a base property some branch redeclares is offered in EVERY branch's
section under that branch's inherited or specialized declaration, so a
branch that does not redeclare it can still supply a required root
argument.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a name both the root and a branch declare is now MERGED rather
than replaced, so a root `minimum` survives a branch `maximum`; a branch
that contradicts the root on a property's `type` describes an unsatisfiable
value and is declined outright. Branch offerability also requires that
`properties` really is an object (a malformed `properties: null` threw)
and that the member's own `type` admits objects.
- rootUnion: `ownFields` becomes `declaredFields` — a branch commonly
specializes a root property, and consumers must render the specialization.
- TUI: the generated select and branch prefixes step aside from any name the
schema itself declares (JSON has no reserved namespace), decode filters them
by exact name, a branch's specialization renders in its own section, and a
`const` is rendered as a one-option select whose typed value is restored
from the schema on submit.
- convertToolParameters: identify the branch from the discriminator constants
the call supplies; when none identifies one, coerce only names every
declaring branch types the same way rather than picking an arbitrary branch.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than
reading one and silently dropping the other's constraints.
- SchemaForm: handle `const` before every type dispatch and for every
constant type, so a numeric/boolean constant, or a schema carrying both
`const` and `enum`, can no longer offer a forbidden value.
- collectSchemaDefaults / TUI initialValue: `const` outranks `default`,
which is an annotation a schema may set to something its const rejects.
- convertToolParameters: resolve from the first branch's merged schema, so a
branch's specialization of a root-declared property is what types the arg.
- TUI: ink-form keys values by field name across the whole form, so two
branches' `kind` were one field and the later section's initial value
decided what the earlier submitted. Branch fields now render under prefixed
names behind a Variant select, and decodeFormValues translates back on
submit, dropping every branch but the chosen one.
Signed-off-by: cliffhall <cliff@futurescale.com>
A tool's `inputSchema` may carry composition keywords at its root — the
2026-07-28 revision makes this explicit — but both form builders enumerate
the root's `properties` and nothing else. A schema keeping its fields on
`allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no
branch picker, no fields, not even the raw-JSON fallback a union-typed
*property* falls back to, so the tool could only be called with empty
arguments.
Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so
they cannot drift on which schemas they can render:
- `allOf` is merged unconditionally (conjunctive — there is no choice to
present), a root `oneOf`/`anyOf` is returned as branches.
- Web `SchemaForm` renders a Variant picker above the fields and prunes the
outgoing branch's values on a switch, since they are no longer visible and
would describe a shape the call is not making.
- TUI `schemaToForm` gives each branch its own section with optional fields —
ink-form is static, and only one alternative applies to a call.
- `convertToolParameters` looks through the branches, so a CLI `--tool-arg`
is typed by the schema that declares it rather than sent as a string.
Required-field gating asks whether *any* branch is satisfied, which is
selection-independent and never blocks arguments the schema accepts. A
`const` is seeded like a `default` and rendered read-only: it admits exactly
one value, so anything else the user could type produces a rejected call.
Adds a `root-union-schemas-http.json` showcase server.
Closes#2123
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3.
- Rename the `--strict` failure from "invalid" to "unportable"
(`EXIT_CODES.SCHEMA_UNPORTABLE`, envelope `schema_unportable`, and the
message text). The module argues at length that these schemas ARE
valid JSON Schema and are merely refused by some clients, so a
machine-readable code of `schema_invalid` told automated callers the
opposite. Done now, while nothing depends on it.
- Add a fixture-path integration case. Every other test built a
`ServerConfig` by hand, so `resolveConfig`'s `rawToolSchemas`
forwarding was uncovered and `unportable-schemas-http.json` was loaded
by nothing — the shipped fixture could have lost its overrides with
the suite still green. It now goes through loadConfig/resolveConfig
and asserts the exact per-tool verdict the README claims.
- Refresh the CLI README's sample output, which still showed the
pre-round-2 wording and fixture, and so dropped the contract warning
that rewording existed to add. Captured from a real run rather than
written by hand, plus a paragraph making the suggestion pattern
explicit.
- Drop the self-referential "[Try it](#unportable-tool-schemas)" link in
the root README, which pointed back at its own section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
- Scope `non-object-root` to `inputSchema`. The pinned SDK types
`inputSchema` with `type: literal("object")` but `outputSchema` as a
bare `looseObject`, so a `{"type":"string"}` output is conforming; the
rule was claiming a requirement MCP does not make, at error severity.
- Decide `untyped-schema` from an allowlist of constraining keywords
rather than a denylist of annotations. JSON Schema ignores keywords it
does not recognize, so `{"vendorHint": true}` accepts every value and a
denylist passed it as constrained. `$defs`-only schemas now flag too.
- Walk `dependentSchemas` and draft-07 `dependencies`, with a test
pinning that the property-name-array form is not a schema position.
- Suggest `anyOf` branches for an array-form `type`, never "drop it from
`required`" — accepting `null` and permitting absence are independent
contracts, so the old suggestion silently changed the schema's meaning.
- Reject `--strict` alongside `--app-info`. That path returns NDJSON from
`runMethod` and never reaches the lint, so the flag was a no-op gate
that could never fail.
- Colour the web sidebar flag from the `--inspector-*` severity tokens,
matching the detail panel and avoiding the default yellow that failed
the a11y check.
- Add a live integration test for `rawToolSchemas`. It caught that an
`outputSchema` override on a preset returning no structured content
makes every call to that tool fail client-side validation — the
showcase config did exactly that, so the flagged tool could not be
run. Moved the bare `true` onto `get_temp`, documented the caveat, and
pinned both halves.
- Drop the double cast from the lint test fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#1005
A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.
Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.
Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.
`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Five `scripts/*.mjs` consumers each carried their own `ensureTestServer()`
that returned early when `test-servers/build` was already on disk. Presence
is not freshness: once that directory exists — which it does on any machine
that has run the smokes once — an edit to `test-servers/src` was never
picked up again, and the smoke went on driving the previously-built fixture.
The failure is silent and misattributed. It does not read as "your fixture
is stale"; it reads as a product bug in whatever the smoke is checking.
`scripts/lib/ensure-test-servers.mjs` is now the one place that build is
produced for a script consumer, and it builds unconditionally — memoized
once per process per repo root, so pack-and-verify's two asks stay one tsc
pass while a fresh run always rebuilds. Callers name the entries they
depend on and get one actionable failure message, replacing five that had
drifted. pack-and-verify also stops shelling out to `npx tsc`, which is
what made it the one script unrunnable on Windows (#1939).
`test-servers/tsconfig.json` gains `incremental` so the up-to-date case is
cheap: 3.0s cold, 1.6s incremental here.
Unconditional emit is still not a clean — `--noCheck` leaves the stale .js
of a deleted src file behind — which is unchanged from before and recorded
in the helper's header and AGENTS.md.
Closes#2111
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
The challenge-driven plumbing did not reach the default CLI/TUI flow. With
no stored token in the legacy era, InspectorClient deliberately builds the
transport with no authProvider (so the SDK cannot open a browser before the
callback server is listening), which also leaves challenge interception off;
the 401 then surfaces as the SDK's headerless UnauthorizedError and the
runner calls authenticate() with no challenge in hand. So a server serving
metadata only at the advertised path still failed there — including for the
legacy showcase the README instructs.
Add a passive counterpart to the challenge interceptor: an observer fetch
that reports every 401/403 without touching control flow or the body. The
OAuthManager records the advertised URL from it and uses it in
authenticate() and its CIMD pre-registration. Latest-wins, including
clearing when a later challenge advertises none.
Also: the fixture helper dropped an explicitly supplied empty
resourceMetadataPath by truthiness, bypassing validation and silently
reverting to the well-known route.
Covered by a direct-client integration test that drives connect ->
authenticate() against the real server and asserts the advertised path was
requested and the well-known one never was; verified failing without the
fix.
Signed-off-by: cliffhall <cliff@futurescale.com>
An RFC 9728 `WWW-Authenticate: Bearer resource_metadata="…"` challenge was
parsed and then dropped by the normalized `AuthChallenge`, so every
challenge-driven `mcpAuth()` call ran discovery without it and probed
locations derived from the MCP server URL instead. OAuth failed outright
against any server whose metadata document lives off the well-known path.
The SDK handles this correctly inside its own transports, but the Inspector
never reaches that path — it intercepts the 401 and drives `auth()` itself
to capture the authorization URL and run the step-up scope union.
Carry the advertised URL on `AuthChallenge` as a string (it crosses the web
client's remote-backend boundary as JSON), convert it at the OAuth boundary,
and pass it to the standard challenge leg, the forced step-up leg, and CIMD
pre-registration — which runs before `auth()` and did its own discovery. A
malformed value is ignored rather than surfaced, matching the SDK's parser.
The callback leg needs nothing: SDK `auth()` persists the URL in its
discovery state.
Adds an `oauth.resourceMetadataPath` test-server mode that moves the
document off the well-known path entirely, so the integration test proves
discovery genuinely fails without the fix.
Closes#2071
Signed-off-by: cliffhall <cliff@futurescale.com>
One conflict, scripts/smoke-web-app.mjs: #2056 added a second phase to the
smoke (the same app declaring _meta.ui.domain, asserting it renders from the
dedicated app origin rather than an opaque one) while this branch extracted
the smoke's flow into scripts/lib/mcp-app-flow.mjs.
Resolved by keeping both: phase 2 now runs through the shared helper too,
which grows three knobs it needs and pack:verify does not —
- expectDeepLink: phase 2 re-navigates the SAME page, so the token gate is
already proven and a rejected re-navigate would fail at the connect wait
regardless;
- what: so a phase-2 ready timeout doesn't read as a phase-1 one;
- extraDiagnostics: phase 2 appends the frame list, which is what separates
"never rendered" from "rendered at the wrong origin".
Each is covered by a new unit test, since neither consumer exercises a
failure branch.
pack:verify deliberately drives phase 1 only. The dedicated origin is served
by the same runner the packaging assertions already cover and reads nothing
new from disk (app-origin-controller.ts has no file reads), so a second
browser launch there would buy no packaging signal.
Verified on the merged tree: smoke:web:app passes both phases (dedicated
origin http://127.0.0.1:6278/app-document/<id>), and pack:verify passes end
to end against the real tarball.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4 on #2100. All three findings are the same error:
clients/web/README.md documents the limitation correctly, and every
port-forwarding blurb then said the opposite.
An unpublished/unforwarded port is NOT the opaque-origin fallback case. The
backend's listener binds fine inside the container, so publication succeeds
and the browser is handed a URL it cannot reach — the frame stays blank,
with no console warning, because a cross-origin navigation failure is not
observable from the page. The fallback and its warning cover the failures
the BACKEND can see: no listener, a port that never bound, an older backend,
a network error.
Corrected in the root README's Docker blurb and the migration guide's
third-port recipe. The migration guide's troubleshooting entry had the wrong
symptom for the same reason — a missing forward produces a blank frame, not
an app whose requests are rejected — so it now leads with that and keeps the
rejected-requests case beside it as the neighbouring symptom, since the two
have opposite causes and opposite console output.
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>
Copilot review round 2 on #2100, plus a collision the review did not find.
The default app-origin port was 6276 — which is already
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback.
That port is fixed precisely so an OAuth app can pre-register
http://127.0.0.1:6276/oauth/callback, which makes it the one listener here
that cannot move when something takes it first. A running --web would hold
it and break a later --cli/--tui OAuth flow at its registered redirect URI,
and docs/mcp-app-review.md has those two running side by side. This
listener has a documented dynamic fallback, so this listener yields: the
default is now 6278 (6277 skipped — v1's retired proxy port, which the
migration guide tells people to stop forwarding). A test asserts the two
defaults stay distinct.
Review comments:
- POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The
8MiB check ran after c.req.json() had buffered and parsed the whole
request, and bounded only `html` — so bulk in `csp`, or in keys the route
never reads, was fully materialized before the 413.
- `csp` is validated as an HTTP header value and length-bounded. The
controller hands it to res.writeHead() verbatim, where Node throws
ERR_INVALID_CHAR synchronously — inside the handler for a later,
unrelated request, outside this route's error handling.
- The app-origin port now propagates everywhere the other two do: the CLI
--print-handoff port-forward command (and its test), the Dockerfile's ENV
and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md.
Without it an app declaring _meta.ui.domain is unreachable through every
documented remote workflow.
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>
Copilot review round 1 on #2100.
- POST /api/app-document: `null` and `[1,2]` are valid JSON, so the
destructure that followed threw OUTSIDE the parse try/catch and turned a
malformed body into a 500 instead of the documented 400.
- app-origin controller: the error handler cleared `server` but not
`origin`, and `publish` gates on `origin` — so an error after a successful
listen kept minting URLs on a dead port and the browser never took the
srcdoc fallback. Clear both, and drop the now-unfetchable documents.
- sandbox proxy: drop a stale `srcdoc` before navigating to a dedicated
origin (srcdoc wins over src), and restore the opaque-origin posture —
strip `allow-same-origin`, reset `innerOrigin` — before assigning `srcdoc`
on the fallback path. Not reachable today (each render remounts the
iframe), but the cost of being wrong about that later is an isolation
boundary.
- publishAppDocument tests: replace two `as unknown as Response` casts with
real `Response`s.
- docs: `frame-ancestors` admits the proxy AND the Inspector page (every
ancestor is checked); the shared helper falls back to loopback, not
`'none'`; the showcase origin is `127.0.0.1`, the default bind address.
- tests: null/array body, post-listen error, and both factories receiving a
working `publishAppDocument`.
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>
An MCP App is rendered by handing its HTML to the sandbox proxy as
`srcdoc`, in a frame sandboxed without `allow-same-origin` — the #1565
isolation model. That gives the app document an opaque origin, so every
request it makes carries `Origin: null` and no CORS / OAuth-callback /
API-key allowlist can admit it. `_meta.ui.domain` is the spec field a
server uses to ask its host for a stable, dedicated origin, and it was
being dropped on the floor.
The field is explicitly host-dependent and the Inspector owns no domain
infrastructure, so it treats any non-empty value as a request rather than
an address, and answers with a real loopback origin on its own listener
(MCP_APP_ORIGIN_PORT, default 6276). One shared origin, path-keyed per
document: that delivers what the field is for without minting a port or
DNS name per app, and is therefore not a per-app isolation boundary.
The inner frame is granted `allow-same-origin` on this path only, which
is what makes the origin real. It is not a #1565 regression: the listener
is on its own port, so the app stays cross-origin to both the proxy and
the Inspector. The proxy refuses the grant if the URL's origin equals its
own, and the grant is never reachable from the server-supplied `sandbox`
string, which is still stripped. The per-app CSP is delivered as a real
response header there rather than only as a <meta>.
Every failure path falls back to the default srcdoc render with a console
warning naming the field, rather than blanking the app.
Also fixes a pre-existing `tsc -b` break on v2/main (useServers.test.tsx
passed `metadata: []` where `RequestMetadata` is expected), which has had
CI red since #2087 and blocks the pre-push gate.
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>
Nothing covered the intersection #1859 fell through. `smoke:web:app` drives
the App path but against the repo build tree; `pack:verify` runs against the
installed tarball but only asserted the sandbox proxy page *exists*. So a
packaging regression that ships the file present-but-unreachable — a rename
with a stale reader, a wrong position relative to clients/web/build, a future
"files"/.npmignore edit — passes both.
Extract the drive-the-App-flow half of the smoke into
scripts/lib/mcp-app-flow.mjs and call it from both, rather than copying it:
the deep-link shape is the part that rots silently, and a drifted link fails
as an opaque timeout rather than a mismatch. pack:verify then drives
connect -> open app -> data-app-status="ready" in headless Chromium against
the installed `--web` boot it already starts.
The client under test comes from the install; the App test server stays a
repo fixture.
Also isolates pack:verify's `--web` catalog (the #1977 treatment the smokes
already have). Untidy while the step only did `GET /`; the App deep link
persists a server row, which would make it destructive and non-deterministic
against the developer's real ~/.mcp-inspector/mcp.json.
Unit tests cover what neither consumer can check itself — both CSRF gates and
the appArgs encoding on the deep link, and driveAppFlow's failure branches
against a stand-in page, all dead code from a happy-path smoke's view.
Verified: pack:verify passes end to end against the real tarball, and fails
at the new step alone when sandbox-controller.ts is pointed at a renamed
proxy page (the tarball and on-disk assertions both still pass — the exact
gap this closes).
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses the suppressed review comment on #2099, which was the sharpest of the
three rounds: the guard stopped looking at a package the moment its `external`
entry was removed — so deleting `"undici"` from the web config would have made
the pre-fix bundle pass. That is self-referential, and #2067 was precisely a
MISSING declaration, so the guard covered its regression but not the bug.
Candidates are now the union of two independently-derived lists: each client's
`external` array, and the root manifest's `dependencies` — root-declared by
rule, reached through `core/`, and required to be external in every client
bundle. Removing a package from an `external` list can no longer remove it from
scrutiny.
That immediately surfaced one latent instance of the same rule violation:
`@modelcontextprotocol/ext-apps` is root-declared and reached through
`core/mcp/apps.ts`, but was absent from the CLI's `external` list and was being
inlined into `index.js` — dragging the transitive v1 SDK and zod-to-json-schema
in with it. It is ESM, so it was not failing the way `undici` did; what it
violated was the rule. Adding it drops the CLI bundle from 590KB to 518KB.
It is also a real example of the shape the previous revision could not see: it
was inlined into the entry, emitting no `<pkg>-HASH.js` chunk at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses the Copilot review on #2099.
- `/api/fetch` used the bare global `fetch`. That route is the browser's only
way out to the network — the web client's `environment.fetch` is
`createRemoteFetch()`, which forwards OAuth discovery and token requests
there — so a corporate-proxy user could connect to a server but never
authorize against it, and the PR's "OAuth is proxied now" claim held for the
CLI and TUI but not for web. It now uses the same proxy-aware base fetch, with
an integration test driving a real forwarding proxy (confirmed failing without
the fix).
- `verify:bundle-externals` only recognized an inlining that produced a
`<pkg>-HASH.js` chunk. A *statically* imported package is folded into
`index.js` and emits no chunk, so it would have passed. Detection now keys off
esbuild's `// <path>` module banners, covering both shapes. Because that
depends on unminified output, a bundle with no banners now fails as such
rather than reporting clean — enabling `minify` cannot silently retire the
check. Tests cover the entry-inlined case, the same-prefix case
(`undici-types` must not read as `undici`), and the no-banner case.
- The pre-push pipeline summaries in AGENTS.md, README.md, and
.github/copilot-instructions.md still described `verify:build-gate → smoke`
and omitted the new gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Corporate-proxy users could not connect to any remote MCP server from v2.
Two independent defects, both reported in #2067.
1. The web and TUI bundles inlined undici. It was declared in the root and
`clients/cli` manifests but named in no client's tsup `external` list, and
tsup auto-externalizes only what the *nearest* manifest declares — so
esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is
CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM
`__require` shim and threw `Dynamic require of "assert" is not supported`.
The rejected `import()` surfaced as "the `undici` package is not available",
telling users to install a package that was already present — and could not
have helped, because the specifier had been rewritten to a relative chunk at
build time.
2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into
Node's *built-in* fetch. That couples two different copies of undici at the
dispatcher handler interface, which is not stable across majors: Node 22
embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8
agent handed a built-in undici 7 handler is rejected with
`fetch failed: invalid onRequestStart method`, and the request never leaves
the process. So fixing the bundling alone would only have converted the
web/TUI failure into the CLI's.
Both halves of the pair now come from userland undici — its `fetch` and its
`EnvHttpProxyAgent` — which keeps the handler inside one copy and works
unchanged from the Node 22.19 engine floor through Node 26. Deleting the
wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option:
measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x.
Because undici's `Response` is a different class from `globalThis.Response`,
the proxied fetch re-wraps each response as a genuine global `Response`,
streaming preserved. Without that, `res instanceof Response` is false for
callers that test it — including the SDK's OAuth error formatter, which would
degrade every message to `Raw body: [object Response]`.
The proxy now sits at the bottom of the fetch stack (`environment.fetch` for
the Node clients, and the `createTransportNode` default for the web backend,
its one caller that supplies no `fetchFn`) rather than wrapping the transport's
fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping
a supplied `fetchFn` would discard it; installing it underneath lets
InspectorClient's own wrappers compose over it. A side effect worth having:
OAuth discovery and token requests go through `environment.fetch` too, and were
never proxied before.
Guarding it:
- `undici` is added to all three tsup `external` lists and removed from
`clients/cli/package.json`, so it is root-declared only, per the placement
rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli
8.9.0).
- New `npm run verify:bundle-externals` fails if any package a client declares
external was inlined into that client's bundle anyway. It reads the *built
output*, not the config — those two disagreed for four releases — and derives
what to check from each client's own `external` array, so a newly
externalized package is covered without editing the guard. Verified against
the real pre-fix artifact, which it flags.
- The proxy tests now drive a real forwarding proxy rather than a mock. That
matters: defect 2 was a runtime interface mismatch, which no mock can catch
because no mock ever dispatches.
AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici
slipped past it, and notes the `node -e` trap — probing the bundled chunk with
`node -e` falsely succeeds, because it exposes a global `require` that
satisfies esbuild's guard.
Closes#2067
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Two review findings from round 2, both correct.
1. The regression suite killed its child only after the assertions. If
`assert.rejects` or either assertion failed, execution never reached the
kill, leaving a 60-second child attached to the runner — a suite guarding a
process leak would leak one itself, precisely on the run where the
regression had come back. Teardown is now registered from `onSpawn` via
`t.after`, so it runs on every path out of every case. That is the helper's
own ownership rule applied to the tests; killing an already-exited child is
a no-op, so the early-exit cases pay nothing for it.
2. Document the new shared helper, per the maintenance rules. AGENTS.md's
scripts/lib tree entry now carries announced-child.mjs and what its contract
buys, and the smoke:web:app paragraph records why the spawn/readiness step
moved out of the smoke (same reason teardownWebServer left prod-web-server's
stop()). README's test:scripts row names the new suite alongside
resolve-node-bin's.
Not mirrored into .github/copilot-instructions.md: that file is a distillation
of the rules a reviewer cites against a diff, and deliberately excludes the
project-structure tree and tooling inventory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 5. Both comments upheld; the first is a real hole in
round 4's own fix.
**The ownership guard was in the wrong place, and overclaimed.**
Round 4 checked ownership and then called `release()`. Two problems:
- Check-then-act. The `stat` and the `rmdir` were separate async steps, so a
waiter could replace the directory in between and we deleted the winner's
fresh lock anyway — the very thing the check was added to prevent.
- Skipping `release()` on a mismatch left proper-lockfile's record registered
in its `locks` map, and its `signal-exit` handler `rmdirSync`s every
registered lock with **no ownership check of its own**. An exit during that
window deleted the winner too.
The guard now lives in `options.fs`, which is the single seam *both* removal
paths route through — `removeLock` on release, and the exit handler. It
refuses to remove a directory whose inode and birth time are not the ones
recorded at acquire, and reports the refusal as success so the library's
bookkeeping forgets the lock either way (leaving it registered is what hands
the exit handler a record pointing at the winner's directory).
The check is a `statSync` immediately followed by an `rmdirSync`, with no
`await` between them, so nothing in this process can interleave. **It still
does not close the race** — it is check-then-act across processes, which needs
the compare-and-swap Node does not expose — and saying it "closes the
destructive half outright" was wrong. file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say it narrows the
window rather than closing it. AGENTS.md carries the negative instruction
alongside round 4's, since this is the third round spent on an overclaim.
**"It expires on its own after 10s" is false for the ENOTEMPTY case** the new
test exercises. Stale takeover reclaims through the same `rmdir`, which also
cannot remove a non-empty directory — so nothing clears it, and every later
save fails ELOCKED against it. `releaseAdvice` now branches: by-hand removal
for ENOTEMPTY, expiry for everything else.
Tests: a real child process demonstrates the exit handler removing a
replacement lock (with an **empty** directory — a non-empty one makes
`rmdirSync` fail ENOTEMPTY, which is how the first draft of this test passed
for the wrong reason). The release-failure test now asserts the message says
"by hand" and does not promise expiry. The shim's `rmdirSync` arm carries a
justified `v8 ignore`: it is reachable only from a real process exit, and its
logic is `removeIfMine`, covered through the async arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4 — four comments, all the same finding against the four
files carrying the claim, and it was more than a wording problem.
The comment said a holder whose lock was replaced is told. Checked against
proper-lockfile@4.1.2, that is false in the case that actually happens:
- `updateLock` compares mtime only on its refresh tick — `stale / 2`, so 5s
here — while an ordinary mutation is a read, an scrypt derivation and an
atomic write, comfortably under a second. The tick never runs, so nothing
fires.
- `release` → `unlock` → `removeLock` is an unconditional `rmdir` with **no
ownership check**. So the compromised holder goes on to delete the
*winner's* lock on the way out, ending the winner's exclusion too: one
compromised writer silently becomes two unprotected ones.
The second half is a correctness bug, not a documentation defect, and it is
fixable here even though the takeover race itself is not.
`withSecretFileLock` now records the lock directory's inode and birth time at
acquire and re-checks them before releasing. On a mismatch it **declines to
release** — leaving the new holder's lock alone costs nothing, ours is
already gone — and warns.
Inode and birth time rather than mtime, deliberately: both survive the
library's own `utimes` refresh, so a lock held past one tick is not accused of
being compromised, and both change on a delete-and-recreate, which is exactly
the event to detect. Where a filesystem reports neither, the two reads agree
and the check concludes "ours", degrading to the library's unaided behaviour
rather than to a false alarm. An unreadable baseline at acquire likewise
answers "ours" — with nothing to compare against, accusing a healthy lock
would be worse than releasing it.
Detection is therefore **best-effort**, and file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say so rather than
promising the loser is told. AGENTS.md carries the negative instruction too,
since this is the second round spent on the same overclaim.
Tests: "does not delete the winner's lock after being taken over" replaces the
lock inside the body and asserts the replacement survives with the *same*
inode and birth time — it was left alone, not deleted and recreated. It waits
for nothing, being the fast case the library's tick misses. Verified by
mutation: forcing `stillOurs` to always answer true fails it. A second test
covers a release that fails while the lock genuinely *is* ours (a stray file
makes `rmdir` fail ENOTEMPTY), so the ownership check cannot swallow a real
release failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
`lint` failed only on errors, so a warn-level rule was a channel nothing
consumed. `react-hooks/exhaustive-deps` reported two stale-closure
`useCallback` deps in App.tsx on every run, `npm run ci` passed anyway,
and the defect was caught only by a review round on #2076.
Add `--max-warnings 0` to all six lint scopes — each of
clients/{web,cli,tui,launcher}'s `eslint .`, plus the root's `lint:core`
and `lint:shared` — so a warning fails `lint`, and therefore `validate`
and `ci`. All six were already warning-free, so no suppressions, rule
downgrades, or ignore-list widening were needed.
Also promote `react-hooks/exhaustive-deps` to `error` in both React
scopes (web, tui). The flag makes severity irrelevant to the gate; it
does not make it read correctly in an editor.
Document the invariant in AGENTS.md as a new "Lint has no warning tier"
section, note it in the README beside the type-aware-linting paragraph,
and mirror it into .github/copilot-instructions.md.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2, both comments upheld.
**ELOCKED must not degrade.** Exhausting the retries on a held lock means the
lock is *working* and something else demonstrably holds it — so running the
mutation anyway entered the exact interleaving the lock exists to prevent,
and entered it knowing a concurrent writer was there. `set` now fails
instead. Two supporting changes:
- The retry budget went from ~3.3s to ~15s so it outlasts the 10s stale
window. Refusing is only defensible because a *crashed* holder resolves by
takeover first; with the shorter budget, one Inspector killed mid-save
would have failed every later save on the box until someone deleted the
lock by hand. Pinned by a new test that stages a dead holder's lock
directly (mkdir + backdated mtime) and asserts the save succeeds.
- Acquire is now probe-then-wait. `proper-lockfile` drives its whole acquire
through `retry`, which re-attempts on *any* error — so with a 20-retry
budget a read-only `$HOME` would have spent the full 15s re-issuing an
identical failing `mkdir` on every save before degrading. One retry-less
probe separates "held" (worth waiting out) from "unavailable" (not).
**The comment overclaimed what proper-lockfile provides, and the reviewer was
right about the mechanism.** Verified against lib/lockfile.js@4.1.2: on
EEXIST it stats, and if stale it rmdirs and re-mkdirs *without* checking the
directory it removed is the one it found stale — so a slow waiter can still
delete a fast waiter's fresh lock and both proceed. That is the same race the
hand-rolled version could not close, and it is not closable without
renameat2(RENAME_EXCHANGE).
What the library actually adds is that the loser is *detected*: its refresh
tick compares the lock's mtime against the value recorded at acquire, so a
compromised holder is told rather than proceeding silently. And what is
genuinely exclusive is the case that matters — mkdir is atomic and a live
holder refreshes its mtime, so its lock never goes stale and two running
Inspectors are serialized. The residual window opens only after a holder dies
without releasing, and the optimistic verify still covers it.
file-lock.ts, README.md, specification/v2_servers_file.md and AGENTS.md all
carried the overclaim; all four now state the narrower, true version.
Also fixes a test that was quietly asserting the opposite of its name: its
holder used stale: 60_000 while the waiter used 10_000, and staleness is
judged by the *waiter's* threshold — so the holder was declared stale after
10s and the save succeeded by takeover.
Not done: a two-process stale-takeover race test. It would be testing the
library rather than this code, and is inherently nondeterministic; the honest
response to that finding was to stop claiming the race is closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
#1950 shipped FileSecretStore without cross-process mutual exclusion, by
decision: an earlier revision hand-rolled a `mkdir` election with an owner
stamp, a heartbeat and a stale-takeover, and three consecutive review rounds
found a real race in it. The last one is not closable with what Node exposes
— claiming a stale lock atomically needs compare-and-swap on a directory
entry (`renameat2(RENAME_EXCHANGE)`) — so it was replaced with optimistic
verify-and-retry and the residual documented.
#2082 settles that as "borrow, don't hand-roll". `proper-lockfile` is what
npm itself locks with, and stale-takeover is precisely the problem it has
already solved: it re-stats the lock directory after claiming it and gives
the lock up when the mtime is not the one it wrote, so the loser of a
takeover race releases instead of proceeding.
- core/auth/node/file-lock.ts: `withSecretFileLock`. `realpath: false` so a
file can be locked into existence (the very first `set` has no
`secrets.json`, and the library's default resolves through `fs.realpath`);
a warning in place of the library's `onCompromised`, which throws from a
timer and would take the session down; and a degrade-with-one-warning path
rather than a throw when no lock can be taken — this store exists for boxes
missing the usual mechanism (#1848, #1905) and must not gain a new way to
be unavailable.
- FileSecretStore.mutate holds it across the whole read-modify-write. The
optimistic verify stays underneath and is not redundant: a lock is advisory
between the processes that take it, so the verify covers a writer outside
this codebase and covers the degrade path. The in-process queue stays too,
and gains a second job — proper-lockfile is not reentrant, so serializing
per path keeps ELOCKED meaning "another process".
- absorbFileSecretsIntoKeyring takes the same lock around orphan adoption and
the atomic claim, behind a lock-free `readdir` fast path so the common
startup (keychain available, no file ever written) neither creates a lock
directory nor warns about one it could not create.
proper-lockfile is a root `dependency` per the placement rule, and is named
in all three bundler `external` lists: tsup externalizes what the *client's*
manifest declares, so a root-only CJS package was being inlined into the ESM
bundles, leaving esbuild's `Dynamic require of "path" is not supported` shim
that killed `--cli` at import time. That rule was undocumented; it is now in
AGENTS.md beside the placement rule that creates it, and mirrored into
.github/copilot-instructions.md.
Tests: 7 new in file-lock.test.ts driving a real second process (the existing
suite structurally cannot — `serialize` orders in-process callers before the
lock sees them), plus two for the migration fast path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Both review findings from round two.
The routing change sits in the funnel BOTH entry points share, but only the
legacy inbound handler was driven by a test — the modern leg, where an
elicitation arrives as an `input_required` result the MRTR driver unpacks and
retries, was covered by construction alone. Adds
`mrtr_app_choose_option` (a modern MRTR tool whose *embedded* elicitation
carries `_meta.ui.resourceUri`) and drives it live over a real transport: the
app answers, the retry carries that answer through `inputResponses`, and the
server echoes it back as the tool's result. A second case cancels the tool
call while the app is up and asserts the request-scoped signal aborts; a third
runs the same tool against a modern server that never advertised the
capability and asserts the native queue takes it.
The cancellation case turned up a real gap: `tryAppElicitation` mounted an app
for an already-aborted signal (the caller cancelled during an earlier MRTR
round), leaving a modal nobody was waiting on and a promise that could never
settle. It now throws the abort straight through.
Also fixes the smoke count in the root README, which still said "two"
headless-Chromium smokes and then introduced a third.
Signed-off-by: cliffhall <cliff@futurescale.com>
Let a server hand a form `elicitation/create` to an MCP App and return the
app's ordinary `ElicitResult`, with the native elicitation form as the
fallback on every failure.
No second extension, no custom method, and no custom result shape: the only
new wire surface is a nested `elicitation` flag on the existing
`io.modelcontextprotocol/ui` extension on each side, plus
`_meta.ui.resourceUri` on the request. App rendering is selected only when
all four gates hold — client `elicitation.form`, client MCP Apps MIME type,
the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://`
URI on the request.
Only the web client advertises the nested client-side setting, and only
because it has a sandbox renderer: supplying
`InspectorClientOptions.appElicitation` is what opts a client in, so CLI and
TUI keep advertising the MIME type without ever claiming they can resolve an
elicitation through an app.
Routing lives in the one funnel both entry points already use
(`enqueuePendingElicitation`), so the inbound handler and the MRTR driver
cannot diverge. Ownership is request-scoped — a per-request id keys the
renderer, iframe and bridge — so two concurrent elicitations can never
resolve through each other's bridges. An explicit `decline`/`cancel` is a
completed elicitation and goes back to the server; everything else (absent or
malformed metadata, resource/sandbox/bridge failure, an app with no
elicitation capability, a timeout, an invalid result or one that fails the
requested schema) falls back.
The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet
consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5)
predates that PR. `core/mcp/appElicitation.ts` and
`AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for
deletion once a release containing it ships. One consequence needed its own
seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that
strips `elicitation`, so an app that correctly advertises it would look like
one that did not, silently turning every negotiated elicitation into a
fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and
prefers the bridge's own value once it carries the key.
`AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so
the same renderer and bridge factory serve an App tool and an elicitation
without either faking the other's shape.
Adds the public fixture (`app_choose_option` + a self-contained
`ui://demo/choose-option.html` app covering accept/decline/cancel), its two
showcase configs, and `smoke:web:elicit`, which drives the negotiated path
end to end in headless Chromium and then the same tool against a server that
never advertised the capability, asserting the native form takes it.
Closes#1854
Signed-off-by: cliffhall <cliff@futurescale.com>
The headline is a correction to my own claim rather than a new defect.
**The optimistic verify is weaker than the comment said it was.** It
described the residual as a crash between the write and the verifying read.
The reviewer pointed out that no crash is needed: order two writers as
write-A, verify-A, write-B, verify-B and both report success while A's entry
is gone — A's verify simply ran before there was anything to see, and B did
nothing wrong. A crash is one instance of that shape, not the whole of it.
The trace in the old comment quietly assumed A verifies *after* B writes,
which nothing guarantees.
Corrected in the doc comment, the README and the spec, and the honest
summary is now stated rather than implied: this converges in every
interleaving where the clobber lands before the verify, it is not mutual
exclusion, and a deployment that needs the guarantee wants an OS-backed lock
from a dedicated library — not another hand-rolled election. It remains
better than what it replaced, which lost updates across a wider set of
interleavings and could not be closed at all with what Node exposes.
The other findings:
- **An envelope naming the cipher still is not an openable one.** Round 9
added a `kdf`/`data` presence check; three dot-separated strings satisfied
it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt
throw — so the quiet "Encrypted file" footer went to a file whose next
save was guaranteed to fail. Now validates part sizes, a non-empty
ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two
`N` synchronously, out of the derivation rather than the decrypt). A
positive control asserts a file we actually wrote still reads as encrypted.
- **`encryptionUnknown` + `looseMode` claimed an exposure it had not
established.** With both set, `plaintext` is absent, so a `!== false` test
landed on "anyone who can read it can read the secrets in it" — about a
file that may well hold ciphertext we simply could not classify. Three-way
now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a
test on each.
- **The README described a lock that no longer exists**, which is the worst
kind of stale doc: it stated a durability guarantee the code had stopped
providing. Rewritten to describe the actual design, including the residual.
- **The private-method access in the concurrency test** now carries the
justification the repo's TypeScript rules require: the asserted shape is
`writeMap`'s real signature, so a rename breaks the line instead of
silently detaching the stub, and a production injection seam would let the
real write path drift from the tested one.
Also updates `specification/v2_servers_file.md`, which still described #1356's
world: one keychain-only store, and unavailability as a hard error. It now
covers store selection and the probe, the file format and lazy encryption
upgrade, the concurrency design and its residual, the `getStrict`/`getMany`
seams and the two traps `getStrict` hit, the durability gate on migration,
the keychain hand-off and its conditional delete, and how the active store is
surfaced.
Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed
once under full-suite parallelism and passes in isolation with and without
these changes — a flake, not a regression.)
Signed-off-by: cliffhall <cliff@futurescale.com>
Seven findings, all taken.
- **The descriptor went stale after the upgrading write.** Round 1 made it
read the file instead of the policy, but it was still computed once and
cached beside the selection — so the first `set` under a newly-set
passphrase encrypted the file while `/api/config` kept serving "still
unencrypted" for the rest of the session, describing bytes this very
process had just changed. The *selection* stays cached (the banner, the
payload, and the store doing the writing must agree on which store it
is); the file descriptor is re-derived per call, which costs one read of
a small file per config fetch.
- **The mount check asked the wrong question.** Comparing a directory's
`st_dev` against its parent's detects a mount *at* that path, but a
volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an
ordinary subdirectory sharing its parent's device — so a durable
container setup was judged unmounted and demoted to session-only
`memory`. It now reads `/proc/self/mountinfo` and asks whether the
directory is *under* any mount other than `/`, which also catches the
same-device bind mount no `st_dev` test can see. Where mountinfo is
unreadable (macOS, Windows, a `/proc`-less container) it falls back to
the device comparison, now against `/` rather than the parent.
- **A decrypted payload was cast, not checked.** GCM proves the bytes are
authentic, not that they parse to the right shape. An array passes
`typeof === "object"`, takes the named assignment, and then
`JSON.stringify` drops every named property of an array — so `set` would
resolve having written a file without the secret it was handed. Silent
loss on the one operation whose entire contract is that it does not lose
things. Both branches now validate a non-array object of string values,
and a shape refusal is deliberately not rethrown as a key mismatch,
which would send the user to fix a working passphrase.
- **The read-modify-write cycle was only serialized in-process.** A
durable file is shared state and a second Inspector on the same box (a
CLI run beside a web session) could read the same map and overwrite the
first one's save, both reporting success. Mutations now take a
`mkdir`-based lock. A lock left by a killed process is stolen after 10s
— one `docker kill` must not brick secret writes permanently, which
would be a worse failure than the race. If the lock cannot be created at
all, the write proceeds unlocked rather than denying a real user a real
secret to prevent a race with a process that may not exist. Reads do not
lock: `writeStoreFile` is atomic, so a reader sees the old file or the
new one, never a torn one.
- **The permission repair was fire-and-forget while the docs promised
0600.** A file owned by another user, or on a read-only mount, stayed
exposed while the footer described it as protected. It now verifies the
resulting mode and warns, naming the actual mode, when it could not be
tightened.
- **`Group`'s theme carried a domain-named `secretStorageFooter`
variant**, which AGENTS.md reserves for app-wide primitive styling.
Renamed to the structural `stickyModalFooter`; the secret-storage
meaning stays in the element that owns it.
- The PR description still claimed the salt made a short passphrase safe,
contradicting the corrected comment and README. Updated.
Tests: both mount layouts that motivated the change (mount-at and
mount-above) plus the sibling-prefix, space-escaped, first-run, and
no-mountinfo cases; the array and non-string refusals on both the
plaintext and the authentic-ciphertext paths (the latter through a
hand-built fixture, since the store's own writer cannot produce that
state); lock exclusion, stale-lock stealing, the un-lockable fallback, and
two independent store instances racing; the permission report in all three
states; and the descriptor no longer going stale within one process. Full
`npm run ci` green — 5,688 tests under the coverage gate.
Signed-off-by: cliffhall <cliff@futurescale.com>
Six findings, all taken.
- The descriptor reported the *write policy*, not the file. Adding
`MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
file flipped `store.encrypted` immediately, so the banner and footer
claimed "File (encrypted)" while the existing secrets sat readable
until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
reads the envelope (never the payload, so it needs no passphrase) and
the descriptor is built from that. The transitional state gets its own
`pendingEncryption` flag, which changes the advice without softening
the verdict — "set a passphrase" is the wrong thing to tell someone who
already has, so the caveat names the pending write instead.
- The secrets file ignored the configured storage directory. A container
mounting only that directory was judged unmounted, fell back to
`memory`, and lost secrets the user had arranged to keep.
`defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
that already relocates OAuth tokens and `client.json` — between the
explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
such variable exists.)
- `version` was never checked before the plaintext branch, so a
`{ version: 2, encryption: "none" }` file was read and silently
rewritten as version 1, discarding whatever the newer format added —
the same destroy-to-satisfy-an-additive-request case the encrypted
branch already refuses. Checked first now, so the answer no longer
depends on which encryption mode the newer writer chose.
- The doc comment claimed a per-file salt made "a short, memorable value
safe to use". It does not: the salt defeats precomputation, not
guessing, and the scrypt cost is deliberately a few milliseconds
because it runs on every read and write. Both the comment and the
README now say to use a high-entropy value, generated rather than
chosen.
- `usableSecretStorage` validated only `kind`, so a partial
`{ kind: "file" }` was cast through and — because a missing `plaintext`
is falsy — rendered as the quiet, neutral *encrypted* file. That is the
confident half-answer the guard exists to reject, produced by the
absence of information rather than by any claim the backend made.
`reason`, `durable`, and each kind's own required fields are checked.
- The footer's inline `Tooltip` carried four static props; extracted to a
`CaveatTooltip` constant per the `.withProps()` rule.
Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#1950
Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.
Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.
- `FileSecretStore` — one `0600` JSON document at
`~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
`MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
whole map is encrypted as a unit rather than value-by-value so the
account names — `${serverId}:${field}` — are hidden too; a per-value
scheme would leave a readable index of which servers you hold a client
secret for. A file that can no longer be decrypted reads as empty
(matching the interface's read tolerance) but *refuses to be written*,
because replacing a file of still-valid secrets to satisfy an additive
request destroys data.
- `secret-store-selection.ts` — the policy.
`MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
otherwise probe the keychain and, if unreachable, fall back loudly. The
fallback is `memory` in a container whose secrets directory is not on a
mount, and `file` otherwise — so mounting the volume the README already
recommends for the catalog flips the same run to durable storage with
no configuration, and an unmounted container gets the honest answer
rather than a file `--rm` will discard. Cached per process so the
banner, `/api/config`, and the store doing the writing cannot disagree.
- Automatic, but never silent. The downgrade prints at startup, rides
`GET /api/config`, and is named in a permanent 32px footer at the
bottom of both dialogs that accept a secret. A startup banner is seen
once by whoever started the process; a toast is seen once; a
dismissible banner is the thing a user dismisses before doing the work
it describes. The plaintext and memory cases render their caveat inline
in a warning tone — a warning you must hover to discover is not one.
- `SecretStoreUnavailableError` is now the base every store's `set`
throws, so the routes translate one condition into a 503 instead of
matching `KeychainUnavailableError` by name.
Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.
Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.
Signed-off-by: cliffhall <cliff@futurescale.com>
`npm audit --omit=dev` reported one high-severity finding in the
production tree, on both `main` and `v2/main`:
nanoid <3.3.18 — GHSA-2v37-7h3g-55p8
custom generators can loop indefinitely when size is zero
It arrives transitively, via vite -> postcss -> nanoid. `postcss`
depends on `nanoid@^3.3.11`, so the fix is an in-range lockfile bump in
all five installs — no package.json changed. `npm audit --omit=dev` is
now clean at the root and in each of the four client installs.
Applied as a targeted `npm update nanoid` per install rather than
`npm audit fix`. The latter reaches the same clean audit but re-resolves
the whole tree, and here dragged in an unrelated esbuild downgrade
(0.27.7 -> 0.27.2) across web/cli/tui — roughly 700 lines of lockfile
noise for a one-package security patch.
The issue also asked whether `vite` belongs in root `dependencies`,
since that placement is why this advisory appears under `--omit=dev` at
all. It does, and deliberately: `start-vite-dev-server.ts` imports
`vite` and `@vitejs/plugin-react` at runtime for `--web --dev`, and
`tsup.runner.config.ts` lists both as `external`, so a published install
resolves them from the root manifest. Moving them to `devDependencies`
would break `--web --dev` for consumers while passing every local check.
Left in place, with the reasoning recorded in the README's
dependency-placement paragraph so it need not be re-derived.
Verified: `npm audit --omit=dev` reports 0 vulnerabilities in all five
installs; `npm run ci` passes.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
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>