Skip to content

Allow configuring the advertised MCP Apps sandbox URL #1862

Closed

Description

@eitansuez

Allow configuring the advertised MCP Apps sandbox URL (e.g. MCP_SANDBOX_FULL_ADDRESS)

Summary

Please add a way to override the public MCP Apps sandbox URL returned by GET /api/config as sandboxUrl, independent of the process bind address.

Today the sandbox listen URL is built in clients/web/server/sandbox-controller.ts roughly as:

sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`;

When the server binds 0.0.0.0, urlHost is forced to localhost. There is MCP_SANDBOX_PORT for the port, but no env/config for scheme + hostname (unlike v1’s MCP_PROXY_FULL_ADDRESS for the proxy).

Why this matters

In remote / container / lab setups the browser does not share the Inspector process’s localhost:

  • UI is reached at a public hostname, e.g. https://inspector.example.com/
  • Sandbox is (or can be) routed separately, e.g. https://inspector-sandbox.example.com/sandbox
  • /api/config still advertises http://localhost:6275/sandbox
  • “Open App” points the iframe at the browser’s localhost → connection refused / mixed content on HTTPS

Routing port 6275 and setting ALLOWED_ORIGINS is not enough; the advertised sandboxUrl must be a URL the browser can actually open.

This also affects Docker, k8s ingress, and any reverse-proxy hostname split between UI and sandbox.

Proposed solution

Something parallel to v1 MCP_PROXY_FULL_ADDRESS, for example:

MCP_SANDBOX_FULL_ADDRESS=https://inspector-sandbox.example.com/sandbox

Behavior:

  1. If set, use it as sandboxUrl in GET /api/config (and banner).
  2. If unset, keep current bind-derived http://<host>:<port>/sandbox behavior.
  3. Document that the process must still listen on MCP_SANDBOX_PORT (or equivalent) and that the public URL must be routed to that listener.
  4. ALLOWED_ORIGINS remains responsible for UI origin / frame-ancestors (orthogonal).

Alternatives considered

  • SSH/port-forward both 6274 and 6275 so the browser uses localhost (works for dev; not for public Instruqt/k8s hostnames).
  • Rewrite /api/config at the gateway (works as a lab hack; shouldn’t be required).
  • Patch sandbox-controller.ts locally.

Related

  • Docs already note forwarding both UI and sandbox ports for remote App review.
  • #1276 is related remote/embed friction (referrer allowlist), but not the advertised URL override.
  • v1 precedent: MCP_PROXY_FULL_ADDRESS.

Environment

  • @modelcontextprotocol/inspector v2.0.0
  • Instruqt / k3d: UI on public HTTPS hostname; sandbox needs a second public hostname → pod :6275

Activity

  1. eitansuez commented on Jul 31, 2026

    @eitansuez
    Author

    Local implementation (lab-verified)

    In this branch is an implementation for this issue, which I verified with an instruqt lab that launches an mcp inspector tab connecting to the example mcp server system-monitor-server from the mcp-apps extension.

    there were three distinct parts:

    1. exposing the environment variable MCP_SANDBOX_FULL_ADDRESS
    2. a packaging issue that left out sandbox.html apparently
    3. a sort of allow origin issue that prevented loading the page.

    sharing intent + verification rather than a PR.

    Behavior before

    • /api/config advertised http://localhost:6275/sandbox under 0.0.0.0 bind
    • Public hostname / HTTPS UI → Apps iframe unusable

    Behavior after

    1. MCP_SANDBOX_FULL_ADDRESS — public sandbox URL in /api/config (listen port unchanged; pin with MCP_SANDBOX_PORT)
    2. Ship clients/web/static in the npm packlist (otherwise Docker/npm ENOENT on /sandbox)
    3. Inject ALLOWED_ORIGINS into sandbox_proxy.html referrer check (was localhost-only)

    Env that worked in lab

    • MCP_SANDBOX_PORT=6275
    • MCP_SANDBOX_FULL_ADDRESS=https://<sandbox-host>/sandbox # path required
    • ALLOWED_ORIGINS=https://<inspector-ui-host> # origin only, no path

    Verification

    • sandbox-controller integration tests
    • Instruqt: Open App on “Get System Info” works end-to-end
  2. cliffhall commented on Aug 1, 2026

    @cliffhall
    Member

    Triage: Priority = Low

    Scored 5/16 with the priority rubric added to AGENTS.md in #1891, as part of the backlog triage in #1872. Added to project board #28 with Status Incoming — meaning filed and scored but not yet approved for work. A maintainer decides whether it moves to Todo.

    Axis Score Reasoning
    Severity / impact 2/5 A configurability gap. The sandbox works; its advertised URL just can't be pointed elsewhere.
    Urgency / staleness 3/5 Wanted for non-loopback deployments, no deadline.
    Signal bonuses +0 none apply
    Total 5 Lands in the Low band (5 or below).

    Related issues. One connected issue:

    Scores are a starting point, not a verdict — the rubric is meant to be overruled when it's plainly wrong, provided the reason is written down.

  3. added this to the v2.5.0 milestone on Aug 5, 2026
  4. added
    containerizationRelated to the docker image or containerized operation in general
    on Aug 5, 2026
  5. cliffhall commented on Aug 29, 2026

    @cliffhall
    Member

    Assessment: implementable, but harder to get right than it looks — and the premise needs checking first

    I reviewed this against the code it touches (clients/web/server/sandbox-controller.ts, clients/web/static/sandbox_proxy.html, clients/web/server/app-origin-controller.ts, clients/web/server/web-server-config.ts) and then went and verified the Docker claim empirically rather than reasoning about it.

    Short version: the advertised-URL override is not dangerous in itself — it's a string in /api/config. But one of the three parts touches a real security boundary, and the whole thing sits on top of the MCP Apps sandbox, which is the most delicately-guarded surface in the client and the one we least want to break. Meanwhile the motivating premise — that this is needed for containerized deployment — does not hold: MCP Apps works in Docker today, verified below.


    First: does it work in Docker right now?

    Yes. Verified, not assumed.

    Built the image from the current v2/main Dockerfile, ran it with the port publishing the README documents, and drove the real connect → open app → data-app-status="ready" chain in headless Chromium against the container, reusing scripts/lib/mcp-app-flow.mjs so the assertion is the same contract smoke:web:app uses.

    docker run -d -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \
      -e MCP_INSPECTOR_API_TOKEN=… mcp-inspector-1862
    

    Phase 1 — default (opaque-origin) render, mcp-app-http.json: PASS

    frame origins:
      http://localhost:6274/?serverUrl=…&autoConnect=…&openApp=mcp_app_demo  =>  http://localhost:6274
      http://localhost:6275/sandbox                                          =>  http://localhost:6275
      about:srcdoc                                                           =>  null
    PASS — app reached ready inside the container
    

    Phase 2 — dedicated origin (_meta.ui.domain, #2056), mcp-app-domain-http.json: PASS

    frame origins:
      http://localhost:6274/?serverUrl=…                                     =>  http://localhost:6274
      http://localhost:6275/sandbox                                          =>  http://localhost:6275
      http://localhost:6278/app-document/2e91ba76680b5390e7cec8367a442789    =>  http://localhost:6278
    PASS — app reached ready inside the container
    

    That is exactly the intended isolation model: host, sandbox proxy, and app document each on a distinct origin, with the default render's inner document at the opaque null origin. The banner advertised http://localhost:6275/sandbox and the browser reached it.

    So the container case — the one deployment scenario we actually ship and support — is not broken, and is not an argument for this change.

    And a prior blocker sitting in front of the remote case

    While testing whether a non-loopback hostname works, I hit something upstream of the sandbox entirely. Browsing the same container at http://inspector.localtest.me:6274 (a public DNS name that resolves to 127.0.0.1, so a genuinely non-loopback origin pointed at the same server), the connect fails before any App is involved:

    data-status="error"
    data-error-message="crypto.randomUUID is not a function"
    

    crypto.randomUUID only exists in a secure context — HTTPS, or a localhost/127.0.0.1 origin. core/mcp/inspectorClient.ts calls it unguarded in trackRequest (and several other places; note line 692 already carries a ?? Math.random() fallback, so the inconsistency is visible in-file). The result is that the web client is dead over plain HTTP on any non-localhost host — a LAN IP, a container published on a routable address, a plain-HTTP reverse proxy — for reasons that have nothing to do with the sandbox.

    That matters for this issue in two ways: it means the remote story is broader than an advertised URL, and it means anyone reaching for MCP_SANDBOX_FULL_ADDRESS over plain HTTP will hit a different wall first.


    If we do implement it, these are the things to design in

    1. Same-origin collapse — the one genuine security risk.

    The MCP Apps spec requires host ≠ sandbox origin. Today that is structural: the sandbox is on its own port, bound by the same process, so it cannot be misconfigured. An operator-supplied full address makes it configurable — and the most natural reverse-proxy setup, https://inspector.example.com/sandbox → :6275, is precisely the one that collapses it.

    Two backstops do fail closed: the proxy's window.top?.alert() self-test throws "The sandbox is not setup securely", and the #2056 src check refuses an app origin equal to the proxy's or the host's (sandbox_proxy.html ~L212, "the app origin MUST differ from BOTH trusted origins"). But those are last-ditch, and the user-visible result is a blank frame with a console throw. This needs a server-side check that refuses to advertise a sandbox URL whose origin matches any allowed UI origin, with the reason printed in the banner.

    2. Widening the referrer check is the delicate half — and it isn't optional.

    ALLOWED_REFERRER_PATTERN in sandbox_proxy.html is hardcoded:

    /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/

    An https:// referrer cannot match that at all, so on any HTTPS deployment the proxy throws before anything else runs. Part 3 of the branch is therefore a prerequisite, not an optional extra. Doing it safely:

    • Compare new URL(document.referrer).origin against an exact list. A regex over the raw referrer string is prefix/substring-attackable (https://evil.com/?x=https://inspector.example.com).
    • The list is injected into a <script>, so JSON.stringify + </script> escaping. This converts the proxy from static bytes into a template — acceptable, but the value also becomes EXPECTED_HOST_ORIGIN, which is the proxy's allowlist for parent messages.
    • Net widening is small: frame-ancestors is already derived from allowedOrigins via sandboxFrameAncestors(), and CSP — not the referrer — is the real control. So this aligns two lists that should already agree.
    • ALLOWED_ORIGINS replaces the defaults rather than merging, so a deployment that sets it for a public host silently loses loopback.
    • Referrer is not a reliable control regardless: a Referrer-Policy: no-referrer on the Inspector page breaks Apps outright, today and after.

    3. Trust model of the value itself.

    The outer iframe deliberately has no sandbox attribute (AppRenderer.tsx ~L550), and the ext-apps host bridge posts with targetOrigin: "*" (confirmed in dist/src/app-bridge.js). So whatever origin this env var names becomes an unsandboxed postMessage peer of the Inspector page. That's tolerable for operator-supplied config — an operator already has strictly greater powers — but it argues for validating the shape (absolute http(s), no wildcard) rather than passing the string through raw. I would not give it a DANGEROUSLY_ prefix: it doesn't widen a bind or disable auth, and the bad configurations should be rejected, not renamed.

    4. Scope gap the issue doesn't mention: the app-origin listener (:6278) has the identical problem.

    Add MCP_SANDBOX_FULL_ADDRESS alone and an app declaring _meta.ui.domain still gets a URL built from the container's bind host. Per the README that is the one MCP Apps failure with no fallback — a blank frame, no console warning, because the cross-origin navigation failure isn't observable from the page. Also appDocumentEmbedders() feeds new URL(sandboxUrl).origin into the published document's frame-ancestors, so the override has to flow there or published documents get CSP-blocked. Either do both listeners, or explicitly document _meta.ui.domain as unsupported behind a proxy.

    5. Smaller things.

    • Part 2 of the branch (packaging clients/web/static) is already fixed — it's in the root packlist as of Apps sandbox proxy omitted from package #1859. That part is stale.
    • Mixed content: an http:// override under an HTTPS UI is silently blocked by the browser. Validate the scheme against the UI's and warn.
    • The "path required" quirk (…/sandbox must be included, since the value replaces the whole URL) is a footgun — validate it or append it.
    • The v1 precedent (MCP_PROXY_FULL_ADDRESS) is weaker than it looks: v1's proxy origin was not an isolation boundary the way the sandbox origin is, so "v1 had one" isn't sufficient justification on its own.

    Recommendation

    Nothing here is a reason to refuse the request outright, but three things argue for leaving it in Incoming rather than scheduling it:

    1. The stated motivation doesn't hold for the deployment we ship. Docker works today, both App paths, verified above.
    2. It is not a one-line env passthrough. It needs origin validation that refuses a same-origin sandbox URL, an exact-origin (not regex) referrer check with escaped injection into a page that is currently static, and a decision about the :6278 listener — all inside the sandbox, which is the surface where a mistake is a real isolation failure rather than a rendering bug, and which only two smokes (smoke:web:app, pack:verify) exercise at all.
    3. Remote/public-hostname hosting is a larger project than this issue. The crypto.randomUUID finding above shows the Apps sandbox isn't even the first thing that breaks off-localhost. If we want to support that scenario, it should be scoped as such rather than approached one env var at a time.

    If we do take it, the honest framing is "make the Inspector deployable behind a reverse proxy", with this as one sub-task — and part 3 (the referrer check) probably deserves its own issue, since it's a standalone defect: the sandbox proxy cannot load from an HTTPS embedder at all.

  6. removed this from the v2.5.0 milestone on Aug 29, 2026
  7. added this to the v2.7.0 milestone on Sep 7, 2026
  8. self-assigned this
    on Sep 14, 2026
  9. cliffhall commented on Sep 14, 2026

    @cliffhall
    Member

    Shipped in #2370 (merged to v2/main): MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS advertise public addresses for the MCP Apps listeners behind a reverse proxy, validated and refused when they would share the Inspector's origin, and the sandbox proxy's referrer check now accepts exactly the ALLOWED_ORIGINS its frame-ancestors admits, so an HTTPS Inspector page can load it. Documented in docs/environment-variables.md and the web client README.

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

Metadata

Metadata

Assignees

Labels

containerizationRelated to the docker image or containerized operation in generalenhancementNew feature requestmcp-appsMCP Apps relatedv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions