fix: ship clients/web/static so the MCP Apps sandbox proxy loads - #1934
fix: ship clients/web/static so the MCP Apps sandbox proxy loads#1934cliffhall wants to merge 6 commits into
Conversation
`clients/web/static/sandbox_proxy.html` is a committed source file, not a build artifact, and it was never listed in the root package.json "files" allowlist. `sandbox-controller.ts` reads it at runtime as `<runner dir>/../static/sandbox_proxy.html`, so it resolved fine in-repo but was absent from every published tarball — the read threw and the controller served its "Sandbox not loaded" fallback page, breaking the Apps tab for anyone running `npx @modelcontextprotocol/inspector`. v1 shipped the equivalent as `server/static` in both the root and the server workspace "files" lists; that entry has no counterpart in v2. Add `clients/web/static` to the allowlist, and assert it in pack:verify twice over — once in the tarball packlist and once on disk after install, since what the runtime needs is the path *relative to* clients/web/build rather than mere presence in the tarball. No .npmignore change is needed: clients/web/.gitignore does not list `static`, so the nested-gitignore packlist hazard that hid `build/` does not apply here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
The `mcp_app_demo` tool and `mcp_app_demo_widget` UI resource presets already existed in the preset registry, but no config wired them into a server — so there was no runnable way to reach a rendered MCP App, and therefore no way to exercise the sandbox proxy path that #1859 broke. `mcp-app-http.json` composes the two over plain streamable-HTTP. With it, the Apps tab renders a real widget and the packaging bug is reproducible on demand: without clients/web/static in the tarball the widget area shows "Sandbox not loaded: ENOENT … /clients/web/static/sandbox_proxy.html" instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
… Chromium `smoke:web:browser` stops at first paint and never connects to a server, so everything downstream of the connect — the Apps tab, the sandbox controller, the UI-protocol bridge — was unexercised by any smoke. That is the code #1859 broke, and nothing would have caught a regression in it. smoke:web:app boots the same prod --web server, spawns the mcp-app-http.json composable server, and drives connect → open app → widget ready through one deep-link navigate. The assertion is the documented data-app-status="ready" contract, which the renderer only reports once the widget has loaded inside the sandbox iframe AND completed its bridge handshake — so a single attribute covers the proxy being served, the UI resource loading, and the handshake. Two mechanics found by running it, both silent-failure shaped: - server-composable.ts announces readiness on stderr, not stdout, so watching stdout alone times out with an empty diagnostic. Both streams are scanned. - the bound port is not the configured one — createTestServerHttp resolves via findAvailablePort(), which walks upward when the port is taken. The smoke parses the announced URL instead of assuming 3130. Scope: this runs against the repo build tree like every other smoke, so it would NOT have caught #1859 itself (a packaging failure; the file is always present in-repo). pack:verify owns that dimension — the two are complements, and the header comment says so. A cheap structural pre-check does assert the proxy page sits where sandbox-controller.ts resolves it, so a move/rename fails fast. Both failure modes verified by hand: with clients/web/static removed, the pre-check fires; with the pre-check bypassed, the data-app-status assertion catches it independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
There was a problem hiding this comment.
Pull request overview
This PR fixes a packaging regression where clients/web/static/sandbox_proxy.html was omitted from the published npm tarball, breaking the MCP Apps sandbox proxy at runtime, and adds smoke coverage to exercise the Apps flow end-to-end.
Changes:
- Add
clients/web/staticto the rootpackage.json"files"allowlist so the sandbox proxy HTML ships in the published package. - Extend
pack:verifyto assert the proxy file is present both in the tarball and after install on disk. - Add an MCP Apps headless-browser smoke (
smoke:web:app) plus a composable test-server config to drive connect → open app →data-app-status="ready".
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
package.json |
Ships clients/web/static and wires smoke:web:app into npm run smoke. |
scripts/pack-and-verify.mjs |
Adds tarball + installed-path assertions for the sandbox proxy HTML. |
scripts/smoke-web-app.mjs |
New Playwright smoke to validate the Apps sandbox/bridge path using a real MCP App server. |
test-servers/configs/mcp-app-http.json |
New composable server config that serves an MCP App tool + UI resource. |
README.md |
Documents the new showcase config and packaging invariant for clients/web/static. |
AGENTS.md |
Updates smoke documentation to include smoke:web:app. |
.github/workflows/main.yml |
Updates workflow commentary to reflect the added smoke. |
…e table Copilot review on #1934, both comments actionable: - smoke:web:app defaulted to 6299 — the same port smoke:web uses — while its own comment claimed the port was "distinct … so back-to-back runs can't collide". The three web smokes run sequentially in `npm run smoke`, so it passed, but a slow teardown, a TIME_WAIT socket, or a parallel run would EADDRINUSE it. Moved to 6297 (smoke:web 6299, smoke:web:browser 6298) and rewrote the comment to name the actual values rather than assert distinctness. - The showcase table tells readers to connect with Protocol Era = Modern unless noted, and mcp-app-http.json is legacy-only. That was stated in the prose below but not in the row, so a reader scanning the table would try Modern first. Marked the row. Verified: smoke:web:app passes standalone on 6297, and the full `npm run smoke` chain passes with all three web smokes on distinct ports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/smoke-web-app.mjs:297
- The failure/cleanup path only includes
pageErrorsin diagnostics and only fails onpageErrors.length > 0. If the page hits an async crash (Uncaught (in promise) …/ failed dynamic import), it will currently be ignored. After capturing console errors, include fatal console diagnostics in both the catch block and the final assertion so the smoke fails on those runtime errors too.
try {
await Promise.race([server.whenChildExits(), drive()]);
} catch (err) {
await fail(
`${err instanceof Error ? err.message : String(err)}${
pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : ""
}`,
);
}
if (pageErrors.length > 0) {
await fail(`app logged uncaught error(s): ${pageErrors.join("; ")}`);
}
scripts/smoke-web-app.mjs:225
- This smoke only records
pageerrorevents; unhandled promise rejections and failed dynamic imports (which Playwright reports via theconsolechannel) can slip through and still let the test pass. For consistency withsmoke-web-browser.mjs, captureconsole.errormessages and filter for the fatal patterns so async crashes fail the smoke instead of being missed.
This issue also appears on line 285 of the same file.
const pageErrors = [];
page.on("pageerror", (err) =>
pageErrors.push(err instanceof Error ? err.message : String(err)),
);
Copilot's second pass (suppressed comments) caught a real gap: the smoke listened only for `pageerror`, which is the *synchronous* half of the uncaught-crash class. Its async twin — an unhandled rejection, or a failed dynamic import (this app lazy-loads chunks) — is not a `pageerror`; Chromium reports it on the console channel. So an async crash during the app-open path could leave the smoke green. smoke-web-browser.mjs already solved this; this now mirrors it exactly — same FATAL_CONSOLE pattern, same split between hard failures and diagnostics, so benign noise (a font-CDN miss, a React warning) still can't flake CI. Fatal console errors are now included both in the catch-block diagnostics and in the final assertion, which is where they were missing. Confirmed working on a passing run: a benign 409 subresource error is reported as a non-fatal diagnostic rather than failing the smoke. Full `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
|
Copilot's second pass reported "no new comments" but listed 2 suppressed comments — both correct and now fixed in 556aaca. Async crashes could leave the smoke green.
Verified on a passing run — a benign Full |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/smoke-web-app.mjs:177
spawn()failures emit anerror(and often onlyclose, notexit). With noerrorlistener, this can crash the smoke with an uncaught event, or hang until the 30s timeout even though the child never started. Captureerror/closeand fail fast with a clearer diagnostic.
const child = spawn(
process.execPath,
[composableServer, "--config", appConfig],
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
);
Copilot's third pass (suppressed comment) caught the remaining hole in the
child-process handling: `spawn()` reports a failure to start via an `error`
event, not `exit`. With no `error` listener Node throws it uncaught, replacing
the smoke's diagnostic with a raw stack — and because `exit` never fires in that
case, the readiness poll would otherwise spin for the full 30s before reporting
a timeout that misattributes the cause. `close` is now listened to alongside
`exit` for the same reason: it fires in cases `exit` does not.
prod-web-server.mjs already documents this exact hazard for the launcher child;
this brings the test-server child in line.
Both new branches verified by hand:
- unspawnable executable → "could not spawn the MCP test server (…): spawn
/nonexistent/node-binary ENOENT", immediately, instead of an uncaught throw
- missing module → "MCP test server exited early" with the loader stack, fast,
with no 30s hang
Full `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
|
Third pass again reported "no new comments" with 1 suppressed comment, and it was right again — fixed in 5401e52.
Full |
Closes #1859
The bug
clients/web/static/sandbox_proxy.htmlis a committed source file, not a build artifact, and it was never listed in the rootpackage.json"files"allowlist.clients/web/server/sandbox-controller.tsreads it at runtime:__dirnameis the built runner atclients/web/build/, so that resolves toclients/web/static/sandbox_proxy.html. In the repo the file is there and everything works; in the published tarball it was absent, thereadFileSyncthrew, and the controller served itscatchfallback — the literal "Sandbox not loaded" page in the issue screenshot. The Apps tab was broken for everyone onnpx @modelcontextprotocol/inspector.Consistent with the reporter's own workaround: hand-creating
static/sandbox_proxy.htmlunder the cached npx install fixed it.Why it slipped
v1 shipped the equivalent as
server/static, listed in both the root"files"and theserverworkspace's — that entry simply has no counterpart in v2's allowlist.Worth distinguishing from the earlier
clients/web/buildpackaging bug: that one was a nested-.gitignorehazard, where npm's packlist honoredclients/web/.gitignoreover the root allowlist. This is not that —clients/web/.gitignoredoesn't liststatic, so no.npmignorechange is needed. The entry was just missing.The fix
package.json— addclients/web/staticto"files".scripts/pack-and-verify.mjs— assert it twice: in the tarball packlist, and on disk after install. The second is not redundant — what the runtime needs is the path relative toclients/web/build, not mere presence in the tarball.README.md— a packaging-invariant bullet recording why the directory ships and why it must land at that exact location.Verification
npm run ci— clean (109 test files, 462 tests).npm run pack:verify— passes, and the two new assertions are what prove this fix:That run installs the real tarball into a clean throwaway consumer via
npm install <tgz>and drives the installed bin — so the sandbox URL above is served from the packaged artifact, not the repo tree.End-to-end proof: a real MCP App, rendered from the installed tarball
pack:verifyproves the file ships. To prove the Apps tab actually works, this PR also adds a composable test server that serves a real MCP App.The
mcp_app_demotool (carrying_meta.ui.resourceUri) and itsmcp_app_demo_widgetUI resource already existed as presets — no config had ever wired them into a server, so there was no runnable way to reach a rendered App.test-servers/configs/mcp-app-http.jsoncomposes them over streamable-HTTP.The smoke deliberately drives the installed tarball, not the repo tree — a dev-mode run would prove nothing here, since the repo always worked:
Run twice against the same installed package — once as shipped, once with
clients/web/staticrenamed away (which recreates the pre-fix tarball exactly, since shipping that directory is the entire fix):data-app-statusreadyui/message, andmcp-app-demo initializedin App logsstatic/removedloading(never resolves)Sandbox not loaded: ENOENT … /clients/web/static/sandbox_proxy.htmlThe failing run reproduces the issue screenshot exactly — same error, same path (
…/node_modules/@modelcontextprotocol/inspector/clients/web/static/sandbox_proxy.html), which is the pathjoin(__dirname, "../static/sandbox_proxy.html")resolves to fromclients/web/build/.Regression cover:
smoke:web:appsmoke:web:browserstops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge — the code this bug broke — were unexercised by any smoke. This PR addssmoke:web:app(innpm run smoke, so CI runs it): it boots the prod--webserver, spawns the new App test server, and drives connect → open app → widget ready through one deep-link navigate, asserting the documenteddata-app-status="ready"contract.Writing it surfaced two silent-failure mechanics worth recording, both now handled and commented:
server-composable.tsannounces readiness withconsole.error— stderr, not stdout. Watching stdout alone timed out after 30s with an empty diagnostic.createTestServerHttpresolves viafindAvailablePort(), which walks upward when the port is taken (observed binding 3131, then 3132). The smoke parses the announced URL rather than assuming3130.Both failure modes were verified by hand — with
clients/web/staticremoved the structural pre-check fires; with that pre-check bypassed, thedata-app-statusassertion catches it independently.Scope, stated plainly: this runs against the repo build tree like every other smoke, so it would not have caught #1859 itself — that was a packaging failure, and the file is always present in-repo.
pack:verifystill solely owns the packaging dimension. The two are complements:pack:verifyproves the file ships,smoke:web:appproves the App path works. The script header and the AGENTS.md entry both say so, so a future reader doesn't assume the packaging case is covered.Follow-up worth considering
pack:verifyneeds network, so it remains a local/release check — meaning the packaging dimension of this bug class still cannot fail a PR in CI.smoke:web:appnarrows the blast radius (a broken sandbox/bridge now fails CI) but does not close that specific gap, since it runs against the repo tree. A durable guard would be averify-format-coverage-style check cross-referencing runtimejoin(__dirname, …)asset reads against the"files"allowlist. Happy to file it separately if wanted.🤖 Generated with Claude Code
https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F