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>
`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>
Nine findings (six inline, three suppressed), all taken. Two were serious.
**The memory fallback made the migrations destructive.** Both migrations —
mcp.json in the remote server, client.json in `node-persistence` — lift a
plaintext secret off disk, write it to the secret store, and then delete it
from the file. That trade is safe only while the store is at least as
durable as the disk, which held by construction while `InMemorySecretStore`
was a test double. Making it a production fallback broke it: on an
unmounted container a plain `GET /api/servers` would have moved a user's
existing secrets into RAM and lost them at exit — destroying data on a
read.
`SecretStore` gains an optional `isDurable()`, absent meaning durable, and
both migrations withhold the disk delete when it answers false (the value
is still loaded, so the session behaves normally). The production fallback
is now a named `SessionSecretStore` rather than the bare
`InMemorySecretStore`, so the suite's many doubles keep standing in for a
*working keychain* — which is what they are there to be — and only code
that deliberately chooses RAM in production says so.
**Round 2's per-call descriptor never reached anyone.** Both web servers
resolved it once at startup and baked it into `initialConfig`, which
`/api/config` serves unchanged — so the freshness fix was inert for its
only consumer. The route now takes a `secretStorageResolver` and calls it
per request.
The lock needed three more fixes, and they compound:
- **Release removed whatever directory was at the path.** A holder whose
work outran the stale threshold would delete the *next* owner's lock,
letting a third writer into a section two processes believed they held.
Ownership is now stamped with a random token and release only removes a
lock still carrying it.
- **The timeout fell through to an unlocked write** — under contention,
the one moment another writer is provably active. It now throws
`SecretFileLockTimeoutError`, which `set` is allowed to do.
- **The first write of every process bypassed the lock**, because the
secrets directory does not exist yet and `mkdir` failed ENOENT. The
parent is created first. First writes are exactly when two processes are
most likely to start together.
Fixing the timeout exposed a fourth: with stale (10s) below timeout, a
waiter stole from a holder that was merely *slow* rather than dead —
reinstating the lost update. A holder now heartbeats its lock, so
"stale" means the holder is gone, and the timeout is what a live-but-slow
holder produces. Lock timings are injectable so the tests can exercise
contention, staleness, and timeout without spending the real intervals.
Also: the loose-permission state is carried into `SecretStorageInfo` as
`looseMode` and rendered as the caveat (it outranks the encryption caveat —
a file others can read is a live exposure, while "unencrypted" is a
property of a file only its owner can open), rather than living in a
startup log the browser cannot see; and `usableSecretStorage` validates
`pendingEncryption` and `looseMode` as the optional booleans/numbers they
are (`pendingEncryption: "false"` is truthy and would have printed advice
that is the opposite of the truth) and rejects file-only fields on a
keychain or memory descriptor.
Tests: both migrations against a session store with a durable control
alongside, so the guard is visibly the difference; `/api/config` serving a
changing resolver and falling back without one; lock ownership after a
steal, the timeout under a live holder, no-steal from a slow holder, and a
locked first write; `looseMode` through the descriptor, the caveat
ordering, and the tone; and five more rejected payload shapes. Full
`npm run ci` green — 5,704 tests under the coverage gate.
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>
Copilot round 2 (PR #2039) surfaced no new visible comments but six
suppressed ones. Four were actionable.
`clients/tui/src/App.tsx` — a stale-completion race in the new banner:
start disconnecting server A, switch to B, then let A's disconnect
reject, and A's failure appears under B. Rapid same-server retries had
the same shape, and the stale attempt is the one most likely to reject.
Claim a monotonic attempt token before awaiting and publish only if it
is still current and the server has not changed; `handleConnect` bumps
the same counter so a connect retires an in-flight disconnect. The
rejection is still owned when superseded — just not published. The
regression test drives a deferred rejection across a server switch, and
fails without the guard.
`tsconfig.lint.json` — the root type-aware block matches
`test-servers/src/**/*.tsx` but the parser project omitted that
extension, so such a file would have failed with "not found in any of
the provided project(s)" instead of being linted. Added, and AGENTS.md
now states the invariant: the include must stay a superset of the lint
globs.
`clients/web/server/{server,vite-hono-plugin}.ts` — the `open()`
rejection branch was untested, and duplicated. Both were fixed by
removing the duplication: `openBrowser` now lives beside
`printServerBanner` in `web-server-config.ts`, with one implementation
to test. Two copies of a one-line `.catch` is two chances for one to
drift back to a bare `open(url)`. The dev plugin is not booted in tests
— it returns early under VITEST by design — but it calls the same
tested helper.
`core/mcp/remote/node/server.ts` — `closeAbortedStream`'s rejecting-close
path, the entire reason the helper exists, had no test. Exported it (as
this file already does for `requestIdForSendWait` / `mcpParamHeadersOnly`)
and covered both legs, asserting no unhandledRejection fires.
Also corrects AGENTS.md, which said each client lists "both" tsconfig
projects — true for cli/tui/launcher, but web lists four leaf projects.
Signed-off-by: cliffhall <cliff@futurescale.com>
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.
The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.
Measured surface, and the fixes:
- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
invokes subscribers with a bare `subscriber()`, so a promise returned
from the listener is floated one layer up; `closeAbortedStream` owns
the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
caller is a key handler that cannot await, so a failing disconnect was
a live unhandled rejection; it now surfaces the message the way
`handleConnect` does. The rest are `void` at callers that genuinely
cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
returns a promise, now awaited (`scrollAreaStoryAssertions` is async
for the same reason). Two tests were asserting against a provider call
they never awaited. `open(url)` in both web servers now warns instead
of escaping a synchronous callback.
- cli, launcher — already clean.
Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.
Closes#1959
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#1795.
Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms.
Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat(web): catalog vs read-only session launch model + apply --header (#1481, #1483)
Per specification/v2_catalog_launch_config.md, `mcp-inspector --web` now selects
the backend's server-list source and a session-wide writability, instead of
seeding an inert `initialMcpConfig` the UI never read:
- `--catalog <path>` (and `MCP_CATALOG_PATH`) → active WRITABLE catalog
(seed/CRUD/watch), like the default catalog.
- `--config <path>` → READ-ONLY session file: served in the UI but never
written, seeded, or plaintext-secret-migrated — safe to point at a foreign
config (e.g. claude_desktop_config.json).
- ad-hoc `--server-url`/command (+ `--header`) → one server held IN MEMORY
(no file written); `--header` is lifted onto the entry so the seeded
connection actually uses it (closes the #1483 warn-only no-op).
- nothing → default catalog (writable), unchanged.
Backend (`createRemoteApp`): new `writable` + `initialServers` options. GET
/api/servers branches three ways (in-memory / read-only file with no
seed+no migrate / writable catalog); the four mutating routes return 403 when
read-only; `/api/config` carries `writable`; the file watcher is skipped for
in-memory sessions. `--catalog`/`--config` are mutually exclusive and neither
combines with an ad-hoc target or `--header`; `--header` requires an ad-hoc
HTTP/SSE server (clear errors otherwise — no silent no-op).
UI: `useServerListWritable` reads `writable` from /api/config; the Servers
screen hides add/edit/clone/remove/reorder/settings and shows a read-only
banner when false. All new props default to writable=true (back-compatible).
Docs: spec gaps G2/G3/G4 updated; launcher README documents the flags.
Verified end-to-end against the built launcher: --config (writable:false,
file byte-stable, mutations 403), ad-hoc --header (in-memory, header lifted),
and --catalog (writable:true, POST persists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): address review — Mantine <Code> in read-only banner, clarify watcher comment
- Replace raw <code> with Mantine <Code> in the read-only-session Alert
(repo convention: prefer Mantine primitives over raw HTML).
- Add a comment near `watchable` clarifying that a read-only `--config` file
is still watched on purpose (read-only blocks UI writes, not external
observation; `writable` does not gate watching).
Deferred (per reviewer): consolidating useSandboxUrl + useServerListWritable
into one useConfig hook to avoid the duplicate /api/config fetch — optional
follow-up. The redundant ad-hoc header type-guard is kept as defensive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).
Closes#1246.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)
Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.
Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:
- New shared helper `clients/web/server/inject-auth-token.ts` embeds
`<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
`</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
(`core/mcp/remote/constants.ts`).
Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)
Addresses code-review feedback on the token-injection PR:
- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
served the raw index.html off disk via serveStatic, bypassing injection — so
a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
real static assets (paths with a dot) still serve verbatim. Dev already
injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
to sessionStorage (not just the URL-param branch), priming the backstop for
any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
carrying a token isn't cached and served stale after a restart regenerates
the token.
Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(servers): hot-reload server list on external mcp.json edits (#1345)
Watch ~/.mcp-inspector/mcp.json from the backend and push a change event
on a new /api/servers/events SSE channel. The web client subscribes via
fetch + ReadableStream (so the existing bearer-auth contract is
preserved — EventSource can't send custom headers) and re-fetches on
each event. The watcher is lazy: it starts on the first SSE subscriber
and stops when the last one disconnects, so tests that never open the
channel don't spin up a real fs watcher. Self-fires from the backend's
own POST/PUT/DELETE are suppressed by capturing the post-write mtime and
comparing it on each watcher event.
Chokidar is used for cross-platform editor-save semantics (temp-file +
rename produces unlink+add sequences that bare fs.watch handles poorly
on macOS/Linux); awaitWriteFinish coalesces those into a single event.
createRemoteApp now returns a close() to release the watcher, which the
standalone server and the vite dev plugin chain into their existing
close paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR review on hot-reload (#1357)
- writeMcpAndTrackMtime now stat()s the file before writing; if the
mtime differs from the last value we recorded, an external editor
slipped in between our writes and chokidar would coalesce both into
one event whose mtime matches our own write — broadcast directly so
peer subscribers learn about the external edit (review finding 1, 5).
- Split useServers.refresh into a public refresh() (loading toggle) and
an internal refreshInternal(background) the SSE handler calls; the
list now stays visible during background refreshes instead of
flashing skeleton state on every external mcp.json edit (finding 2).
- Coalesce multiple \n\n frames landing in one decode chunk to a single
refresh call so back-to-back broadcasts don't race two concurrent
GETs whose setState order is unspecified (finding 3).
- Clarify createRemoteApp's close() docblock about in-flight SSE stream
callbacks (finding 7).
- Add integration coverage for PUT/DELETE self-suppression and for the
external-edit-between-backend-writes broadcast (finding 6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address pass-2 review on hot-reload (#1357)
- Clarify writeMcpAndTrackMtime comment so a future reader can't
mistake the broadcast for a content-preservation guarantee: the
external edit's content may already have been read-around and
overwritten depending on the interleave with readMcpConfig.
- Drop the no-op GET in the PUT/DELETE self-suppression tests. The
pre-stat branch is short-circuited by `lastWrittenMtimeMs === null`
with or without it; matches the POST test's structure.
- Add explicit no-flicker regression test: tracks every `loading`
value across renders during an SSE-driven refresh and asserts none
flipped back to true. Catches a future regression that reaches for
setLoading(true) in the background path even under React batching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): port v1.5 dev backend (vite-hono-plugin + sandbox + remote-server bootstrap)
Ports the v1.5 in-process dev backend into v2 so `npm run dev` serves the
Hono remote-server (`/api/*`) alongside the Vite-served browser app. Without
this, the browser-side `createRemoteTransport` had nothing to talk to during
local development.
Adds under `clients/web/server/`:
- vite-hono-plugin.ts (Hono middleware on the Vite dev server; gated to
`apply: 'serve'` and skipped when `process.env.VITEST` is set so it stays
inert in vitest/storybook runs)
- sandbox-controller.ts (standalone HTTP server for the MCP Apps sandbox)
- web-server-config.ts (env parsing + initial-config payload + banner;
`autoOpen` defaults off under VITEST so test runs don't shell out to `open`)
- server.ts (standalone Hono prod server for the future v2 launcher, #1246)
- start-vite-dev-server.ts (in-process Vite starter for the future launcher)
- vite-base-config.ts (shared optimizeDeps exclusions)
Wires `honoMiddlewarePlugin(buildWebServerConfigFromEnv())` into
`vite.config.ts` with `apply: 'serve'`. Adds the `open` dependency and a
DOM lib + `@inspector/core/*` path alias to `tsconfig.node.json` so the
dev-backend files type-check alongside vite.config.ts.
Coverage: web-server-config.ts (100% lines), sandbox-controller.ts (91% lines),
and vite-base-config.ts (100%) all clear the per-file gate. The Vite plugin,
prod server, and dev-server starter are excluded from coverage as runtime
glue exercised by `npm run dev` end-to-end (callers covered separately;
their logic lives in core/mcp/remote/node, which integration tests already
exercise).
Closes#1320.
Unblocks #1244 (Wire App.tsx) — its test plan can now run end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web/server): address PR review feedback on the dev-backend port
- sandbox-controller: resolve start() with empty values on listen error
(e.g. EADDRINUSE on a fixed MCP_SANDBOX_PORT) instead of leaving the
promise pending forever. Without this, the Vite plugin's
`await sandboxController.start()` could hang the entire dev backend on a
port collision. Banner now silently omits the sandbox line; getUrl()
keeps returning null. Adds a regression test exercising the EADDRINUSE
path against a real socket.
- vite-hono-plugin: listen for `error` and `close` on the incoming request
stream (in addition to `end`) so an aborted POST/PUT (browser navigates
mid-upload) doesn't leak the middleware indefinitely.
- vite-hono-plugin: add a `.catch()` to the initial `pump()` kickoff so a
sync throw before the first `await reader.read()` can't surface as an
unhandled rejection.
- sandbox_proxy.html: drop the stale CSP comment — v1.5's sandbox HTTP
server never set CSP either; this PR doesn't regress anything. Replace
with the actual isolation model (iframe sandbox attribute + origin
allowlist) so future readers don't assume a CSP header is enforced.
- Hoist `WebServerHandle` to a shared `server/types.ts` so the future
launcher port (#1246) doesn't need to pick one of two duplicate
declarations.
- Extract `resolveAutoOpen()` helper out of the triple-nested ternary in
`buildWebServerConfigFromEnv` for legibility, with the three-way logic
documented in one place.
- Add an inline comment explaining why
`webServerConfigToInitialPayload` falls back to streamable-http for
unknown discriminators (forward-compat with future SDK transports)
rather than throwing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>