Commit graph

2,874 commits

Author SHA1 Message Date
Cliff Hall
5dd7becd22
Merge branch 'v2/main' into v2/fix/2071-oauth-resource-metadata 2026-08-24 22:39:40 -04:00
Cliff Hall
c1a8b666ea
Merge pull request #2116 from modelcontextprotocol/v2/chore/2062-esbuild-override
chore: pin esbuild past the GHSA-g7r4-m6w7-qqqr range in web/cli/tui
2026-08-24 22:12:19 -04:00
cliffhall
baf4fe954f Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
2026-08-24 21:26:59 -04:00
cliffhall
48b393014a fix(auth): address review — observe SSE explicit fetch, isolate test state
- The SSE branch selects a caller-supplied `eventSourceInit.fetch` as-is, so
  it bypassed the challenge observer and a first-time legacy SSE
  authorization lost the advertised URL the same way. Observing is passive,
  so it is applied to that fetch too while the deliberate no-proxy-wrap rule
  stays. Covered behaviorally against a 401-serving server.

- Dropped an `as unknown as typeof fetch` from the new integration test
  (banned by AGENTS.md, and it erased the signature); the spy is typed as
  `typeof fetch` directly. The `as MCPServerConfig` beside it was
  unnecessary too.

- The integration test reused a per-pid OAuth storage file. SDK `auth()`
  persists the advertised URL in its discovery state, so a rerun could
  satisfy discovery from that cache and pass with the plumbing removed —
  which would have quietly falsified the regression check. Fresh mkdtemp
  per run, removed in teardown.

Adds observer unit tests (challenge reported, body untouched, 403 included,
silent on success).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 21:21:38 -04:00
cliffhall
597e1bd25f fix(auth): carry resource_metadata into first-time legacy authorization
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>
2026-08-24 21:04:34 -04:00
cliffhall
8beba25b58 fix(auth): address review — reject a query or fragment in the metadata path
`isOriginRelativePath` compared the resolved href against the literal, which
`URL` preserves a query and fragment in — so `/doc?version=1` and `/doc#section`
passed. Express matches on the path alone (and treats `?` as a pattern
character), and a fragment never reaches the server, so the advertised URL
could not resolve to the registered route. Reject both, with cases added to
the table-driven test.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 20:36:39 -04:00
cliffhall
012f3a062a docs: mirror the overrides rule into copilot-instructions.md
AGENTS.md requires review-relevant rules to be mirrored into
.github/copilot-instructions.md in the same PR, and that file already
carries a Dependency placement section. Add the distilled form of the
new overrides guidance: use an `overrides` entry rather than
`npm audit fix` when the advisory range has no upward escape inside the
parent's declared range, gate such a pin on the build rather than the
audit, expect the lockfile diff to touch only that package, and say when
the pin can be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 20:29:38 -04:00
cliffhall
3be4230391 chore: pin esbuild past the GHSA-g7r4-m6w7-qqqr range in web/cli/tui
`npm audit` (dev included) reported a low-severity advisory against
`esbuild` in three of the four client installs. It is not in the
production tree — `npm audit --omit=dev` has been clean everywhere since
2058 — but a permanently non-clean audit trains people to skim the
channel, which is the same argument 2058 made.

`tsup@8.5.1` declares `esbuild: ^0.27.0`, and the advisory covers
0.27.3 - 0.28.0 with 0.27.7 the last 0.27.x, so there is no upward
escape inside tsup's declared range. `npm audit fix` "resolves" it by
silently downgrading to 0.27.2 across three installs — the trap hit and
reverted during 2058. An `overrides` entry forces the copy above the
range instead.

- Add `"overrides": { "esbuild": "^0.28.2" }` to clients/{web,cli,tui}.
  That also covers the tui's `tsx` copy (0.28.0), so no separate update
  is needed.
- Re-resolve each lockfile. Every changed entry is an esbuild one: the
  nested `tsup/node_modules/esbuild` and `tsx/node_modules/esbuild`
  copies collapse into the single deduped 0.28.2, and no other package
  moves.
- Document the pin in AGENTS.md, including when to drop it.

The real gate here is the build, not the audit: this puts tsup on an
esbuild major it does not declare. `npm run build` succeeds for all
three clients, and `npm run ci` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 20:26:43 -04:00
cliffhall
2a0290df26 fix(auth): address review — proxy CIMD discovery fetch, validate metadata path
Two findings from review:

- `ensureCimdClientRegistration` ran protected-resource discovery on the
  global fetch while its AS-metadata leg used the configured one. On web
  that is `createRemoteFetch`, which proxies through the backend to sidestep
  CORS — so the discovery this PR routes to a challenge-advertised URL would
  fail in the browser, be swallowed by the catch, and leave CIMD probing the
  wrong authorization server. Forward `params.fetchFn`, and assert in the
  test that nothing reaches the global.

- `oauth.resourceMetadataPath` accepted `//other-host/doc`. Resolved against
  the request base that re-points the origin in the advertised
  `resource_metadata` while Express still registers the route locally, so the
  server would advertise a document it does not serve. `/\\host` does the
  same, since the URL parser folds a backslash into a slash for special
  schemes — a leading-slash check misses both. Validate origin-relativity
  with a shared predicate, used by the JSON-config validator and again at
  server setup for a programmatically built ServerConfig.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 20:20:55 -04:00
Cliff Hall
6642f2c611
Merge pull request #2112 from modelcontextprotocol/v2/fix/2093-cli-server-metadata
fix(cli): apply a server's persisted mcp.json metadata
2026-08-24 20:04:47 -04:00
cliffhall
f8a46726b7 fix(auth): pass the challenge's resource_metadata URL to SDK auth()
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>
2026-08-24 19:48:40 -04:00
cliffhall
7da20dcfd0 fix(cli): apply a server's persisted mcp.json metadata
The web client and the TUI both read `savedSettings.metadata` and pass it
as `InspectorClientOptions.defaultMetadata`. The CLI never did, and
`InspectorClient` only reads the option rather than falling back to
`serverSettings.metadata` — so a per-server `metadata` entry was silently
inert for `--cli` runs.

Pass it beside the existing `roots:`, guarded on a non-empty map the same
way web and the TUI guard theirs, so `{}` still means "no defaults".
Precedence is unchanged: call-time keys override defaults in `mergeMeta`,
so `--metadata` stays per-invocation and wins on a key collision.

Covered by three catalog-driven tests asserting against the test server's
recorded wire requests. `createTestConfig` is retyped `StoredMCPServer` —
the accurate on-disk shape — since a fixture that cannot express the
per-server keys cannot cover the settings that reach the client.

Closes #2093

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 19:32:23 -04:00
Cliff Hall
9d0448b644
Merge pull request #2109 from modelcontextprotocol/v2/fix/2042-multiline-string-input
fix(web): let tool string arguments be typed as multiline
2026-08-24 11:36:40 -04:00
Cliff Hall
097a7990a4
Merge branch 'v2/main' into v2/fix/2042-multiline-string-input 2026-08-24 10:50:53 -04:00
cliffhall
d47071d535 fix(web): reset a tool form by row identity, not by tool name
Copilot review round 4 on #2109. A `tools/list` may repeat a name (#1957) and
ToolsScreen already disambiguates those rows with a key of its own, but the
detail panel handed SchemaForm only the name — so switching between two
same-named tools looked like the same entity and left the first one's enlarged
fields (and its in-progress number/JSON text, which predates this PR) in place.

ToolDetailPanel now takes an optional `resetKey`, defaulting to the name for
callers whose list cannot repeat one, and ToolsScreen passes the row key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 10:18:55 -04:00
cliffhall
121ce4f051 fix(web): focus the enlarged field before paint, and derive its section width
Copilot review round 3 on #2109.

- The focus hand-off ran in a passive effect, so the browser presented at least
  one frame with focus on the document body. useLayoutEffect instead.
- The right-section widths were pixel totals. They are now built from the
  measured button widths plus the gap and inset, and emitted through `rem` so
  they track a user's root font size rather than pinning to CSS pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 10:03:44 -04:00
Cliff Hall
04b0fe783b
Merge pull request #2107 from modelcontextprotocol/v2/chore/2003-pack-verify-app-render
chore(ci): render an MCP App from the installed tarball in pack:verify (#2003)
2026-08-24 09:59:27 -04:00
cliffhall
7db596b5e6 fix(web): disable the enlarge button along with its field
Copilot review round 2 on #2109. A disabled form (a tool call in flight) left
the enlarge button live, and activating it swapped in a text area that mounts
disabled — so the focus hand-off targeted an unfocusable element and keyboard
focus was dropped to the document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 09:35:41 -04:00
cliffhall
925ff4de2f fix(web): hand focus to the enlarged text area, and name each button per field
Copilot review round 1 on #2109.

- Activating Enlarge from the keyboard unmounted the focused button and left
  focus on the document body, so the next Tab restarted at the top of the page.
  The text area now takes focus on mount, caret after the text already typed.
- Every field's enlarge button carried the same "Enlarge" name, so a form with
  several string arguments exposed indistinguishable controls. Each now reads
  "Enlarge <field label>". Queries that matched a field by its label alone are
  scoped to the textbox role, since the button's name now contains it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 09:25:50 -04:00
cliffhall
791c2063eb Merge remote-tracking branch 'origin/v2/chore/2003-pack-verify-app-render' into v2/chore/2003-pack-verify-app-render 2026-08-24 09:06:25 -04:00
cliffhall
df938777b1 Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render
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>
2026-08-24 09:05:53 -04:00
cliffhall
5340283fb4 fix(web): let tool string arguments be typed as multiline (#2042)
A string argument renders as a single-line `<input>`, which swallows Enter —
so a value containing newlines could not be typed at all.

Each string field now carries an enlarge button (ImEnlarge2) to the left of its
clear button. Clicking it swaps the input for an autosizing text area that keeps
the field's value, label, and length constraints. It is deliberately one-way:
shrinking back would have to either discard newlines the user typed or keep a
value the field can no longer show, and both are worse than staying enlarged.

Enlarging is per-field and resets with `resetKey`, so it cannot carry across to
another tool's same-named field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 09:05:31 -04:00
Cliff Hall
66763e85ee
Merge pull request #2100 from modelcontextprotocol/v2/fix/2056-app-ui-domain
fix(web): honor _meta.ui.domain with a dedicated app origin
2026-08-24 08:59:18 -04:00
cliffhall
6011d4de03
fix(web): bound retained app documents by size; correct the cookie claim
Copilot review round 5 on #2100.

- The store bounded entry COUNT only, and the route admits 8Mi code units per
  document with a one-hour TTL — ~256Mi retainable on documents a server
  under test chooses the size of. Added a 64Mi retained-size budget evicted
  oldest-first alongside the count bound, with a running total kept in step
  with the map through a single `drop` seam so it cannot drift.
- The isolation claim was wrong about cookies. A separate port makes a
  separate ORIGIN, so DOM access, localStorage, sessionStorage and IndexedDB
  are isolated — but cookies are keyed by host and path, ignoring port, so an
  app served here shares the 127.0.0.1 jar with the Inspector and every other
  loopback service. Not a hole in the Inspector's own auth (the API token is
  a header plus a window global and sessionStorage, none of them cookies) and
  not closable without a distinct host this listener cannot mint, so it is
  stated plainly in both the module header and clients/web/README.md rather
  than left as a claim that is false for one of the three surfaces it names.
- Removed a shell-escaping artifact I left in the migration guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00
cliffhall
73bf0a2522
docs: distinguish a failed publish from an unreachable one
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>
2026-08-24 08:45:40 -04:00
cliffhall
bb3ba91b8a
fix(web): guard the app-origin port and the trusted-origin grant
Copilot review round 3 on #2100.

- MCP_APP_ORIGIN_PORT could equal CLIENT_PORT or MCP_SANDBOX_PORT. The
  app-origin listener starts BEFORE the web server binds, so that is not a
  race it loses — it wins the port and the Inspector dies with EADDRINUSE.
  `CLIENT_PORT=6278` was a valid config before this feature existed. It now
  resolves to a dynamic port with a warning: of the three listeners this is
  the only one that can move, and refusing to boot would cost the user the
  whole Inspector over a feature they may not be using.
- The proxy's same-origin refusal covered only its own origin. An app served
  from the INSPECTOR's origin is no safer: same-origin with the top document
  means direct access to window.top, its DOM and its storage, and the
  cross-origin frame in between buys nothing. Both trusted origins are now
  refused before allow-same-origin is granted.
- Replaced the `as unknown as number` on `server.address()` with a real
  narrowing of the string/null members of its return type.
- MCP_APP_ORIGIN_PORT added to the v1→v2 migration guide's env table, its
  Docker recipe, and a troubleshooting entry for the symptom this actually
  produces (the app renders, but its own backend rejects `Origin: null`).
- clients/web/README.md now states which failure is outside the fallback
  guarantee — a published document the browser cannot reach — and why there
  is no honest signal to fall back on. Docs and code no longer disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00
cliffhall
80f76e365e
test(web): raise the theme-flip waitFor budget
Pre-existing flake, not introduced here — the file is untouched by this
branch and the test already carries a comment about failing this way once.
The default 1000ms was still too tight: it failed twice consecutively under
`npm run ci` (~1021ms, 0 calls) while passing every time the file runs
alone, which is a budget signature rather than a broken observer. A
generous `waitFor` costs nothing on the passing path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00
cliffhall
fafc8367b8
fix(web): move the app origin off 6276, bound the publish route
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>
2026-08-24 08:45:40 -04:00
cliffhall
fa441da85f
fix(web): harden the app-origin paths and correct three docs
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>
2026-08-24 08:45:40 -04:00
cliffhall
41c063b9ec
docs: add the dedicated-origin showcase server to the README
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00
cliffhall
8e8c1bbad9
test(web): prove the dedicated app origin end to end
`frame-ancestors` is checked against every ancestor, not just the parent.
A published app document is framed by the sandbox proxy, which is framed
by the Inspector page — so admitting only the proxy's origin blocked the
frame outright (it rendered as a chrome-error frame that never reached
the bridge). `appDocumentEmbedders` now admits both.

That failure was invisible to every unit test, so `smoke:web:app` grows a
second phase: the same demo app declaring `_meta.ui.domain`, driven
through the same connect → open → ready chain, then asserting the two
facts that only hold on the dedicated path — the inner frame was
navigated to a published document, and its own `location.origin` is a
real http origin rather than "null". Both phases end at
`data-app-status="ready"`, so nothing short of reading the frame's origin
can tell them apart.

Adds `test-servers/configs/mcp-app-domain-http.json` (and a `domain`
param on the `mcp_app_demo_widget` preset) as the showcase server for
both the smoke and hand testing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00
cliffhall
ec40cf5531
feat(web): honor _meta.ui.domain with a dedicated app origin
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>
2026-08-24 08:45:40 -04:00
Cliff Hall
5e43263b09
Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render 2026-08-24 08:45:35 -04:00
Cliff Hall
5de0b8a5ca
Merge pull request #2106 from modelcontextprotocol/v2/fix/2064-csp-drop-warning
fix(web): state the expected shape when dropping a CSP source (#2064)
2026-08-24 08:45:12 -04:00
Cliff Hall
684bde5ba5
Merge branch 'v2/main' into v2/fix/2064-csp-drop-warning 2026-08-24 08:28:08 -04:00
Cliff Hall
4d5ff954b9
Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render 2026-08-24 08:26:43 -04:00
Cliff Hall
c0ad49f8a3
Merge pull request #2105 from modelcontextprotocol/v2/fix/2051-opaque-token-replay
fix(auth): don't answer `token_expired` from an unknowable local expiry
2026-08-24 08:22:37 -04:00
cliffhall
9bf2c0c97e docs: mirror the catalog-isolation rule's widened scope into copilot-instructions (#2003)
Copilot review round 1. The rule at issue is the review-relevant one — a
check that boots the web backend must not touch real user state — and this
PR widens where it applies: pack:verify now navigates that backend, so it
needs the isolation too. Mirrored as one clause rather than a new section.

The rest of the AGENTS.md edit stays unmirrored deliberately: which two
consumers share scripts/lib/mcp-app-flow.mjs is tooling detail nobody cites
against a diff, and AGENTS.md asks the mirror to stay a distillation.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:49:15 -04:00
cliffhall
deca2de604 fix(auth): require JWT shape before trusting exp as proof of validity
Copilot review on #2105: jwtExpiresAtMs is lenient — it parses the second of
any two-or-more dot-separated segments — so a structured opaque token such as
`opaque.<base64url({"exp":...})>` returned a parseable exp and could still
short-circuit re-authorization, recreating the replay loop.

isAccessTokenProvablyUnexpired now checks isJwtFormat first. Tests cover a
two-segment and a four-segment token whose payload decodes to a future exp.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:46:10 -04:00
cliffhall
17c33080f9 chore(ci): render an MCP App from the installed tarball in pack:verify (#2003)
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>
2026-08-24 07:44:58 -04:00
cliffhall
c20fce9ba2 docs(spec): align mid-session auth spec with the token_expired predicate
Copilot review on #2105: specification/v2_auth_mid_session.md still said
checkAuthChallengeSatisfied is satisfied for token_expired / unauthorized by
any valid non-expired token. Only token_expired can short-circuit, and only
for a provably unexpired JWT; invalid_token and unauthorized are always false
(which the code already did before this branch).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:35:40 -04:00
cliffhall
99f8ccde18 docs(web): describe the accepted subset, not "parses as a source expression"
Review catch: `'unsafe-eval'` *is* a valid CSP source expression, so saying
the filter drops entries that "don't parse as a source expression" was both
wrong and a rerun of the misconception this PR set out to fix — and it
contradicted the NOTE two paragraphs down.

The doc now names the accepted subset (`scheme://host[:port][/path]`, a bare
or wildcard host, a scheme such as `data:`, and `*`) and says explicitly that
a CSP keyword is a valid source expression these origin-list fields simply
cannot express. The now-duplicated half of the third paragraph is trimmed.

Comment-only; the warning string and tests are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 01:20:18 -04:00
cliffhall
268bfa1085 fix(web): state the expected shape when dropping a CSP source (#2064)
`approveCspSources` warned "dropping unsafe CSP source", which states a
security verdict the check never makes: `SAFE_CSP_SOURCE` screens each
entry for injection safety only, and deliberately does not bound how
broad a grant is (`resourceDomains: ["*"]` passes).

The four fields are origin lists, so the real cause of a drop is almost
always that the entry isn't an origin — a CSP keyword such as
`'unsafe-eval'` has no field in the contract to be requested through.
Reading it as a safety verdict is what sent #2012 chasing an XSS
regression that wasn't one.

The message now names the field the entry came from (so an app declaring
several lists can tell which one was bad) and states the shape expected,
with no claim about the value's safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 01:02:02 -04:00
cliffhall
d41e4e1dd7 fix(auth): don't answer token_expired from an unknowable local expiry
`checkAuthChallengeSatisfied` answered a `token_expired` challenge with
`isAccessTokenUsable`, which treats "we cannot tell" as "still valid":
`isJwtExpired` returns false whenever the token is not a JWT or carries no
`exp`, and storage never records an absolute expiry (`expires_in` is relative
to an issuance instant that is never persisted).

So for an opaque access token — Doorkeeper, Keycloak in opaque mode,
GitHub-style `gho_…` — the local guess outvoted the resource server's
explicit verdict. Callers short-circuited re-authorization and reconnected
with the same dead credential, looping with no redirect and no ReAuthBanner.
A bare 401 also maps to `token_expired`, so this was not limited to servers
sending `error="invalid_token"`.

Adds `isAccessTokenProvablyUnexpired` beside `isAccessTokenUsable` in
`core/auth/connection-state.ts`: true only for a JWT whose `exp` is still in
the future (same 60s skew). `checkAuthChallengeSatisfied` uses it for the
`token_expired` branch, so absence of local evidence no longer beats the
server, while a provably live JWT keeps its cheap short-circuit.

`isAccessTokenUsable` stays optimistic — it is the right default for
displaying authorization state, and flipping it would show every
opaque-token server as unauthorized.

Closes #2051

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 00:59:02 -04:00
Cliff Hall
a92a73003b
Merge pull request #2099 from modelcontextprotocol/v2/fix/2067-proxy-undici
fix(proxy): route through undici's own fetch and keep undici external
2026-08-23 23:58:15 -04:00
Cliff Hall
4faaf74694
Merge branch 'v2/main' into v2/fix/2067-proxy-undici 2026-08-23 23:57:53 -04:00
Cliff Hall
eac849be44
Merge pull request #2103 from modelcontextprotocol/v2/chore/2102-remove-claude-workflow
chore: remove the Claude Code GitHub Actions workflow
2026-08-23 23:54:06 -04:00
Cliff Hall
f6cff76aed
Merge branch 'v2/main' into v2/chore/2102-remove-claude-workflow 2026-08-23 23:49:45 -04:00
cliffhall
5a9b7c4e7b chore: remove the Claude Code GitHub Actions workflow
Closes #2102

The @claude mention-triggered workflow fired on issue comments, PR review
comments, PR reviews, and issue open/assign, and each trigger now produces
an Actions run that waits for approval. Copilot handles PR review now, so
the workflow no longer earns the surface area it carries: it was the only
job holding ANTHROPIC_API_KEY, granted a tool-enabled agent Bash, and
needed a fork gate plus an untrusted-checkout carve-out to stay safe.

.mcp.json stays — the workflow passed it via --mcp-config, but it is also
the project-level MCP server config the Claude Code CLI reads locally. The
github-actions Dependabot entry stays too, since main.yml still pins
actions by SHA.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 23:39:43 -04:00
cliffhall
531cf84e60 Merge branch 'v2/main' into v2/fix/2067-proxy-undici
One conflict, in `clients/tui/__tests__/App.test.tsx`: both sides added an
`opts` capture to the test harness's `FakeClient` for different fields —
`defaultMetadata` on v2/main (#1910), `environment.fetch` here (#2067) — so the
resolution is the union of the two, keeping both rationales and both tests.

Everything else auto-merged. `npm run ci` passes on the result.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 23:34:07 -04:00