Skip to content

fix: ship clients/web/static so the MCP Apps sandbox proxy loads - #1934

Open
cliffhall wants to merge 6 commits into
v2/mainfrom
v2/fix/package-sandbox-proxy
Open

fix: ship clients/web/static so the MCP Apps sandbox proxy loads#1934
cliffhall wants to merge 6 commits into
v2/mainfrom
v2/fix/package-sandbox-proxy

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #1859

The bug

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.

clients/web/server/sandbox-controller.ts reads it at runtime:

const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8");

__dirname is the built runner at clients/web/build/, so that resolves to clients/web/static/sandbox_proxy.html. In the repo the file is there and everything works; in the published tarball it was absent, the readFileSync threw, and the controller served its catch fallback — the literal "Sandbox not loaded" page in the issue screenshot. The Apps tab was broken for everyone on npx @modelcontextprotocol/inspector.

Consistent with the reporter's own workaround: hand-creating static/sandbox_proxy.html under the cached npx install fixed it.

Why it slipped

v1 shipped the equivalent as server/static, listed in both the root "files" and the server workspace's — that entry simply has no counterpart in v2's allowlist.

Worth distinguishing from the earlier clients/web/build packaging bug: that one was a nested-.gitignore hazard, where npm's packlist honored clients/web/.gitignore over the root allowlist. This is not that — clients/web/.gitignore doesn't list static, so no .npmignore change is needed. The entry was just missing.

The fix

  • package.json — add clients/web/static to "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 to clients/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:

pack:verify — tarball OK: 26 files, no source maps, clients/web/{build,dist,static} present (5.08 MB unpacked)
...
pack:verify — verifying prod `--web` serves the shipped dist from the installed package...

MCP Inspector Web is up and running at:
   http://127.0.0.1:6399?MCP_INSPECTOR_API_TOKEN=pack-verify-token

   Sandbox (MCP Apps): http://127.0.0.1:50470/sandbox

pack:verify OK — published tarball installs clean and the real bin drives web
(prod / served dist), cli (stdio tools/list), and tui (help) end to end.

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:verify proves 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_demo tool (carrying _meta.ui.resourceUri) and its mcp_app_demo_widget UI 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.json composes 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:

npm pack → npm install <tgz> into a throwaway consumer
  → the installed `mcp-inspector --web` bin
  → deep link (?serverUrl=…&openApp=mcp_app_demo&appArgs=…&autoOpen=<token>)
  → Playwright screenshot

Run twice against the same installed package — once as shipped, once with clients/web/static renamed away (which recreates the pre-fix tarball exactly, since shipping that directory is the entire fix):

data-app-status Widget area
With the fix ready Widget renders — host context, ui/message, and mcp-app-demo initialized in App logs
static/ removed loading (never resolves) Sandbox not loaded: ENOENT … /clients/web/static/sandbox_proxy.html

The failing run reproduces the issue screenshot exactly — same error, same path (…/node_modules/@modelcontextprotocol/inspector/clients/web/static/sandbox_proxy.html), which is the path join(__dirname, "../static/sandbox_proxy.html") resolves to from clients/web/build/.

mcp-app-before-fix mcp-app-after-fix

Regression cover: smoke:web:app

smoke:web:browser stops 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 adds smoke:web:app (in npm run smoke, so CI runs it): it boots the prod --web server, spawns the new App test server, and drives connect → open app → widget ready through one deep-link navigate, asserting the documented data-app-status="ready" contract.

Writing it surfaced two silent-failure mechanics worth recording, both now handled and commented:

  • server-composable.ts announces readiness with console.errorstderr, not stdout. Watching stdout alone timed out after 30s with an empty diagnostic.
  • The bound port is not the configured one: createTestServerHttp resolves via findAvailablePort(), which walks upward when the port is taken (observed binding 3131, then 3132). The smoke parses the announced URL rather than assuming 3130.

Both failure modes were verified by hand — with clients/web/static removed the structural pre-check fires; with that pre-check bypassed, the data-app-status assertion 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:verify still solely owns the packaging dimension. The two are complements: pack:verify proves the file ships, smoke:web:app proves 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:verify needs 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:app narrows 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 a verify-format-coverage-style check cross-referencing runtime join(__dirname, …) asset reads against the "files" allowlist. Happy to file it separately if wanted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F

`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
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 5, 2026
@cliffhall cliffhall linked an issue Aug 5, 2026 that may be closed by this pull request
@cliffhall
cliffhall requested review from BobDickinson and removed request for BobDickinson August 5, 2026 21:51
cliffhall and others added 2 commits August 5, 2026 17:59
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/static to the root package.json "files" allowlist so the sandbox proxy HTML ships in the published package.
  • Extend pack:verify to 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.

Comment thread scripts/smoke-web-app.mjs
Comment thread README.md Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pageErrors in diagnostics and only fails on pageErrors.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 pageerror events; unhandled promise rejections and failed dynamic imports (which Playwright reports via the console channel) can slip through and still let the test pass. For consistency with smoke-web-browser.mjs, capture console.error messages 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
@cliffhall

Copy link
Copy Markdown
Member Author

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. smoke-web-app.mjs 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 instead. So a crash during the app-open path could go unnoticed and the smoke would still pass.

smoke-web-browser.mjs had already worked this out and documented it at length; my script just didn't mirror it. It now does — 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 included in both places they were missing: the catch-block diagnostics and the final assertion.

Verified on a passing run — a benign 409 Conflict subresource error is now reported as a non-fatal diagnostic rather than failing the smoke, which is exactly the classification behaving as intended:

smoke:web:app note — 1 non-fatal console error(s): Failed to load resource: the server responded with a status of 409 (Conflict)
smoke:web:app OK — connected to http://127.0.0.1:3130/mcp, opened "mcp_app_demo", widget reached data-app-status="ready" through the sandbox proxy

Full npm run ci green, with all three web smokes on distinct ports.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 an error (and often only close, not exit). With no error listener, this can crash the smoke with an uncaught event, or hang until the 30s timeout even though the child never started. Capture error/close and fail fast with a clearer diagnostic.
  const child = spawn(
    process.execPath,
    [composableServer, "--config", appConfig],
    { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
  );

@cliffhall
cliffhall requested a review from olaservo August 6, 2026 13:51
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
@cliffhall

Copy link
Copy Markdown
Member Author

Third pass again reported "no new comments" with 1 suppressed comment, and it was right again — fixed in 5401e52.

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 since exit never fires in that case, the readiness poll would have spun the full 30s and then blamed a timeout. 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; the test-server child just wasn't brought in line. Both new branches verified by hand:

Injected fault Result
Unspawnable executable could not spawn the MCP test server (…): spawn /nonexistent/node-binary ENOENT — immediate, no uncaught throw
Missing module MCP test server exited early + loader stack — fast, no 30s hang

Full npm run ci green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apps sandbox proxy omitted from package

2 participants