Addresses Copilot review round 2. A reverse proxy routes a public address to
one stable port, so neither MCP_SANDBOX_FULL_ADDRESS nor
MCP_APP_ORIGIN_FULL_ADDRESS is used when the listener's port is OS-assigned:
an explicit 0, a config-time collision resolved to 0, or the EADDRINUSE
fallback. Validation warnings also stop echoing the URL path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
- Never echo a refused value into a warning: credentials, query and
fragment are dropped, and an unparseable value is not shown at all.
- Do not adopt MCP_SANDBOX_FULL_ADDRESS / MCP_APP_ORIGIN_FULL_ADDRESS after
the EADDRINUSE fallback: the reverse proxy still routes them to the pinned
port, which another process holds.
- Refuse a public app origin equal to any trusted ancestor in the controller,
where the real (possibly bind-derived, possibly fallback-port) sandbox
origin is known; config time could not see it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
Add MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS so a reverse
proxy or ingress deployment can advertise the sandbox proxy URL and the
_meta.ui.domain app origin the browser actually reaches, instead of the
bind-derived http://localhost:<port> ones. The process still listens on
MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT.
Both values are validated in public-address.ts and refused (warned, bind-
derived address kept) when malformed or when their origin collapses onto
the Inspector UI's (or, for the app origin, the sandbox's) — the MCP Apps
spec requires host != sandbox origin, and the single-hostname proxy layout
is exactly the one that breaks it. Plain http under an https UI is warned.
The sandbox proxy page's referrer check was a hardcoded
http://localhost|127.0.0.1 pattern no https embedder could match. It now
compares the referrer's parsed origin against an exact list injected by the
controller, derived from the same filtered allow-list as its frame-ancestors
header, with the old loopback check kept only as the no-list fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
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>
`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>
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>