Merge pull request #2370 from modelcontextprotocol/v2/feat/1862-sandbox-public-url

feat(web): configurable public addresses for the MCP Apps listeners
This commit is contained in:
Cliff Hall 2026-09-14 02:10:54 -04:00 • committed by GitHub
commit fd6e60148b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 998 additions and 19 deletions

View file

@ -23,6 +23,18 @@ import {
} from "../../../../server/app-origin-controller.js";
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
/** A port that was free a moment ago, for tests that need a FIXED port. */
async function freePort(): Promise<number> {
const probe: Server = createServer();
await new Promise<void>((resolve) =>
probe.listen(0, "127.0.0.1", () => resolve()),
);
const addr = probe.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return port;
}
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
afterEach(() => {
@ -178,6 +190,73 @@ describe("createAppOriginController", () => {
await expect(res.text()).resolves.toBe("<!doctype html><p>app</p>");
});
it("publishes under a public origin while still serving the bound listener (#1862)", async () => {
const publicOrigin = "https://apps.example.com";
// A fixed port: a public origin is only used where a proxy can route.
const port = await freePort();
controller = createAppOriginController({
port,
host: "127.0.0.1",
publicOrigin,
});
const first = await controller.start();
expect(first.port).toBe(port);
expect(first.url).toBe(publicOrigin);
expect(controller.getOrigin()).toBe(publicOrigin);
// The cached start reports the BOUND port; parsing one out of a public
// origin with no explicit port would yield NaN.
expect(await controller.start()).toEqual(first);
const published = controller.publish({ html: "<p>proxied</p>" })!;
expect(published.url.startsWith(`${publicOrigin}/app-document/`)).toBe(
true,
);
// The reverse proxy's job is routing the public path to this listener;
// simulate it by fetching the same path on the bound port.
const path = new URL(published.url).pathname;
const res = await fetch(`http://127.0.0.1:${first.port}${path}`);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe("<p>proxied</p>");
});
it("does not use a public origin on an OS-assigned port (port 0)", async () => {
// Both an explicit MCP_APP_ORIGIN_PORT=0 and a config-time collision with
// CLIENT_PORT / MCP_SANDBOX_PORT (which buildWebServerConfig resolves to 0)
// land here: there is no stable port for a reverse proxy to route to.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
controller = createAppOriginController({
port: 0,
host: "127.0.0.1",
publicOrigin: "https://apps.example.com",
});
const { url, port } = await controller.start();
expect(url).toBe(`http://127.0.0.1:${port}`);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
}
});
it("does not adopt a public origin when the listener never bound", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
controller = createAppOriginController({
port: 0,
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
publicOrigin: "https://apps.example.com",
});
expect(await controller.start()).toEqual({ port: 0, url: "" });
expect(controller.getOrigin()).toBeNull();
// So the renderer takes its srcdoc fallback instead of a dead URL.
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
} finally {
errorSpy.mockRestore();
}
});
it("serves only frame-ancestors when the document carries no csp", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
@ -350,6 +429,54 @@ describe("createAppOriginController", () => {
}
});
it("does not use a public origin after falling back off its pinned port (#1862)", async () => {
const { port, release } = await claimPort();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
controller = createAppOriginController({
port,
host: "127.0.0.1",
publicOrigin: "https://apps.example.com",
});
const result = await controller.start();
expect(result.port).not.toBe(port);
expect(result.url).toBe(`http://127.0.0.1:${result.port}`);
expect(
controller.publish({ html: "<p>x</p>" })!.url.startsWith(result.url),
).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
await release();
}
});
it("refuses a public origin equal to a trusted ancestor, including a bind-derived sandbox (#1862)", async () => {
// Config time cannot see the bind-derived sandbox origin; the callers pass
// it in embedderOrigins, so the controller is where the collision is caught.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
controller = createAppOriginController({
port: await freePort(),
host: "127.0.0.1",
publicOrigin: "http://127.0.0.1:6275",
embedderOrigins: appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [
"http://localhost:6274",
]),
});
const result = await controller.start();
expect(result.url).toBe(`http://127.0.0.1:${result.port}`);
expect(controller.getOrigin()).not.toBe("http://127.0.0.1:6275");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("must differ from both"),
);
} finally {
warnSpy.mockRestore();
}
});
it("degrades to publish()===null when listen fails for a non-EADDRINUSE reason", async () => {
// The backends await start() during boot; if it ever stopped resolving the
// whole web server would hang. Instead it resolves empty and every publish

View file

@ -0,0 +1,196 @@
/**
* Public addresses for the MCP Apps listeners (#1862): `MCP_SANDBOX_FULL_ADDRESS`
* and `MCP_APP_ORIGIN_FULL_ADDRESS`, as parsed by `server/public-address.ts`.
*
* Pinned here: what each accepts and how it is normalized (`/sandbox` appended
* to a bare origin, the app origin kept origin-only), every refusal reason, the
* same-origin refusals that keep the sandbox and a dedicated app origin off the
* Inspector's own origin, the mixed-content warning, and that a refused value
* never leaks a secret (credentials, query, fragment, path) into the log. The
* listener-side rules — fixed port only, no trusted-ancestor collision — are in
* the sandbox and app-origin controller suites, where the bound port is known.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
resolveAppOriginPublicOrigin,
resolveSandboxPublicUrl,
} from "../../../../server/public-address.js";
const UI = ["http://localhost:6274", "http://127.0.0.1:6274"];
describe("public addresses (#1862)", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
/** The single warning line, or undefined when nothing was warned. */
function warned(): string | undefined {
return (
warnSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n") ||
undefined
);
}
describe("resolveSandboxPublicUrl", () => {
it.each([[undefined], [""], [" "]])(
"is unset, silently, for %j",
(raw) => {
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
expect(warned()).toBeUndefined();
},
);
it("advertises a full URL as given", () => {
expect(
resolveSandboxPublicUrl(
"https://inspector-sandbox.example.com/sandbox",
["https://inspector.example.com"],
),
).toBe("https://inspector-sandbox.example.com/sandbox");
expect(warned()).toBeUndefined();
});
it("appends /sandbox to a bare origin, the only path the listener serves", () => {
expect(
resolveSandboxPublicUrl(" https://sb.example.com ", [
"https://inspector.example.com",
]),
).toBe("https://sb.example.com/sandbox");
});
it("keeps a non-root path, which a reverse proxy may rewrite", () => {
expect(
resolveSandboxPublicUrl("https://sb.example.com/mcp/sandbox", UI),
).toBe("https://sb.example.com/mcp/sandbox");
});
it.each([
["not a url", "not an absolute URL"],
["ftp://sb.example.com/sandbox", "http:// or https://"],
["https://user:pw@sb.example.com/sandbox", "credentials"],
["https://*.example.com/sandbox", "wildcard"],
["http://[::1]:6275/sandbox", "IPv6"],
["https://sb.example.com/sandbox?x=1", "query string or fragment"],
["https://sb.example.com/sandbox#frag", "query string or fragment"],
])("refuses %s", (raw, reason) => {
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS");
expect(warned()).toContain(reason);
});
it.each([
["https://user:hunter2@sb.example.com/sandbox", "hunter2"],
["https://sb.example.com/sandbox?token=hunter2", "hunter2"],
["https://sb.example.com/sandbox#hunter2", "hunter2"],
["not a url hunter2", "hunter2"],
])("never echoes a secret from a refused value (%s)", (raw, secret) => {
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS");
expect(warned()).not.toContain(secret);
});
it("never echoes a path, which can carry a secret too", () => {
expect(
resolveAppOriginPublicOrigin(
"https://apps.example.com/hunter2",
["https://inspector.example.com"],
undefined,
),
).toBeUndefined();
expect(warned()).toContain('"https://apps.example.com"');
expect(warned()).not.toContain("hunter2");
});
it("refuses an origin shared with the Inspector UI — the same-origin collapse", () => {
// The natural reverse-proxy layout: one hostname, /sandbox routed to 6275.
// The spec requires host != sandbox origin, so this must not be advertised.
expect(
resolveSandboxPublicUrl("https://inspector.example.com/sandbox", [
"https://inspector.example.com",
]),
).toBeUndefined();
expect(warned()).toContain("different origin from its host");
});
it("advertises, but warns about, plain http under an https UI", () => {
expect(
resolveSandboxPublicUrl("http://sb.example.com/sandbox", [
"https://inspector.example.com",
]),
).toBe("http://sb.example.com/sandbox");
expect(warned()).toContain("mixed content");
});
it("does not warn about http when every UI origin is http", () => {
resolveSandboxPublicUrl("http://sb.example.com/sandbox", UI);
expect(warned()).toBeUndefined();
});
});
describe("resolveAppOriginPublicOrigin", () => {
const SANDBOX = "https://sb.example.com/sandbox";
const HOST = ["https://inspector.example.com"];
it("is unset, silently, for a blank value", () => {
expect(resolveAppOriginPublicOrigin(" ", HOST, SANDBOX)).toBeUndefined();
expect(warned()).toBeUndefined();
});
it("returns the canonical origin", () => {
expect(
resolveAppOriginPublicOrigin(
"https://Apps.Example.com/",
HOST,
SANDBOX,
),
).toBe("https://apps.example.com");
expect(warned()).toBeUndefined();
});
it("refuses a path, since documents are served at the origin root", () => {
expect(
resolveAppOriginPublicOrigin(
"https://apps.example.com/x",
HOST,
SANDBOX,
),
).toBeUndefined();
expect(warned()).toContain("origin only");
});
it("refuses a malformed value", () => {
expect(
resolveAppOriginPublicOrigin("javascript:alert(1)", HOST, SANDBOX),
).toBeUndefined();
expect(warned()).toContain("MCP_APP_ORIGIN_FULL_ADDRESS");
});
it.each([
["the Inspector UI", "https://inspector.example.com"],
["the sandbox proxy", "https://sb.example.com"],
])("refuses an origin shared with %s", (_label, raw) => {
expect(resolveAppOriginPublicOrigin(raw, HOST, SANDBOX)).toBeUndefined();
expect(warned()).toContain("must differ from both");
});
it("checks only the UI origins when the sandbox URL is bind-derived", () => {
expect(
resolveAppOriginPublicOrigin("https://sb.example.com", HOST, undefined),
).toBe("https://sb.example.com");
});
it("warns about plain http under an https UI", () => {
expect(
resolveAppOriginPublicOrigin("http://apps.example.com", HOST, SANDBOX),
).toBe("http://apps.example.com");
expect(warned()).toContain("mixed content");
});
});
});

View file

@ -1,13 +1,84 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createServer, type Server } from "node:http";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
createSandboxController,
DEFAULT_SANDBOX_PORT,
EMBEDDER_ORIGINS_PLACEHOLDER,
embedderOriginsLiteral,
renderSandboxProxyHtml,
resolveSandboxPort,
sandboxFrameAncestors,
} from "../../../../server/sandbox-controller.js";
/** A port that was free a moment ago, for tests that need a FIXED port. */
async function freePort(): Promise<number> {
const probe: Server = createServer();
await new Promise<void>((resolve) =>
probe.listen(0, "127.0.0.1", () => resolve()),
);
const addr = probe.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return port;
}
const PROXY_PAGE = join(
dirname(fileURLToPath(import.meta.url)),
"../../../../static/sandbox_proxy.html",
);
describe("embedder allow-list injection (#1862)", () => {
it("emits the same filtered list frame-ancestors admits", () => {
const origins = [
"https://inspector.example.com",
"http://[::1]:6274", // dropped by both
"http://a:1; sandbox", // dropped by both
];
expect(embedderOriginsLiteral(origins)).toBe(
'["https://inspector.example.com"]',
);
expect(sandboxFrameAncestors(origins)).toBe(
"frame-ancestors https://inspector.example.com",
);
});
it.each([[undefined], [[]], [["http://[::1]:6274"]]])(
"is null — the page's loopback fallback — when nothing valid remains (%j)",
(origins) => {
expect(embedderOriginsLiteral(origins as string[] | undefined)).toBe(
"null",
);
},
);
it("escapes < so a value cannot close the <script> element", () => {
const literal = embedderOriginsLiteral(["http://x</script><b>:1"]);
expect(literal).not.toContain("<");
// Still the same value once parsed back as JavaScript/JSON.
expect(JSON.parse(literal)).toEqual(["http://x</script><b>:1"]);
});
it("does not interpret replacement patterns in the literal", () => {
expect(
renderSandboxProxyHtml(`a ${EMBEDDER_ORIGINS_PLACEHOLDER} b`, [
"http://$&.example:1",
]),
).toBe('a ["http://$&.example:1"] b');
});
it("finds exactly one placeholder in the shipped proxy page", () => {
// If the page's marker drifts from the constant, the substitution silently
// no-ops and every non-loopback deployment is refused again.
const page = readFileSync(PROXY_PAGE, "utf-8");
expect(page.split(EMBEDDER_ORIGINS_PLACEHOLDER)).toHaveLength(2);
expect(page).not.toContain("ALLOWED_REFERRER_PATTERN");
});
});
describe("sandboxFrameAncestors", () => {
it("derives the directive from the provided allow-list", () => {
expect(
@ -198,6 +269,85 @@ describe("createSandboxController", () => {
}
});
it("serves the proxy page with the allow-list injected", async () => {
const controller = createSandboxController({
port: 0,
allowedOrigins: ["https://inspector.example.com"],
});
try {
const { url } = await controller.start();
const body = await (await fetch(url)).text();
expect(body).toContain(
'const ALLOWED_EMBEDDER_ORIGINS = ["https://inspector.example.com"];',
);
expect(body).not.toContain(EMBEDDER_ORIGINS_PLACEHOLDER);
} finally {
await controller.close();
}
});
it("advertises a public URL in place of the bind-derived one (#1862)", async () => {
const publicUrl = "https://sb.example.com/sandbox";
// A fixed port: a public URL is only advertised where a proxy can route.
const port = await freePort();
const controller = createSandboxController({
port,
host: "127.0.0.1",
publicUrl,
});
try {
const first = await controller.start();
expect(first.url).toBe(publicUrl);
expect(controller.getUrl()).toBe(publicUrl);
// The port is still the bound one — the public URL has none of its own.
expect(first.port).toBe(port);
expect(
(await fetch(`http://127.0.0.1:${first.port}/sandbox`)).status,
).toBe(200);
// The cached second start must report the bound port, not parse one out
// of the public URL (which would be NaN here).
expect(await controller.start()).toEqual(first);
} finally {
await controller.close();
}
});
it("does not advertise a public URL on an OS-assigned port (port 0)", async () => {
// MCP_SANDBOX_PORT=0 leaves no stable port for a reverse proxy to route to.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const controller = createSandboxController({
port: 0,
host: "127.0.0.1",
publicUrl: "https://sb.example.com/sandbox",
});
try {
const { url, port } = await controller.start();
expect(url).toBe(`http://127.0.0.1:${port}/sandbox`);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
await controller.close();
}
});
it("does not advertise a public URL when the listener never bound", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = createSandboxController({
port: 0,
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
publicUrl: "https://sb.example.com/sandbox",
});
try {
expect(await controller.start()).toEqual({ port: 0, url: "" });
expect(controller.getUrl()).toBeNull();
} finally {
errorSpy.mockRestore();
await controller.close();
}
});
it("advertises localhost in the sandbox URL for a wildcard bind", async () => {
// 0.0.0.0 isn't reachable from the browser, but a wildcard bind serves
// loopback — so the URL handed to the client (and printed in the banner)
@ -379,6 +529,31 @@ describe("createSandboxController", () => {
}
});
it("does not advertise a public URL after falling back off its pinned port (#1862)", async () => {
// The reverse proxy routes the public URL to the pinned port, which another
// process holds — so the public URL would reach the wrong listener.
const { port, release } = await claimPort();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const controller = createSandboxController({
port,
host: "127.0.0.1",
publicUrl: "https://sb.example.com/sandbox",
});
try {
const result = await controller.start();
expect(result.port).not.toBe(port);
expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`);
expect(controller.getUrl()).toBe(result.url);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
await controller.close();
await release();
}
});
it("resolves with empty values when even the dynamic retry fails", async () => {
// The Vite plugin awaits start() in configureServer; if start() ever stops
// resolving, the entire dev backend hangs. This pins the resolve-on-error

View file

@ -27,6 +27,8 @@ const MUTATED_ENV_KEYS = [
"ALLOWED_ORIGINS",
"MCP_SANDBOX_PORT",
"MCP_APP_ORIGIN_PORT",
"MCP_SANDBOX_FULL_ADDRESS",
"MCP_APP_ORIGIN_FULL_ADDRESS",
"SERVER_PORT",
"MCP_LOG_FILE",
"MCP_AUTO_OPEN_ENABLED",
@ -342,6 +344,40 @@ describe("buildWebServerConfigFromEnv", () => {
},
);
it("leaves the public addresses unset by default (#1862)", () => {
const cfg = buildWebServerConfigFromEnv();
expect(cfg.sandboxPublicUrl).toBeUndefined();
expect(cfg.appOriginPublicOrigin).toBeUndefined();
});
it("resolves both public addresses against the resolved allow-list", () => {
process.env.ALLOWED_ORIGINS = "https://inspector.example.com";
process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com";
process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://apps.example.com";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox");
expect(cfg.appOriginPublicOrigin).toBe("https://apps.example.com");
});
it("refuses public addresses that collapse onto the Inspector or sandbox origin", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
// The DEFAULT allow-list is what the sandbox is checked against when
// ALLOWED_ORIGINS is unset — so the loopback UI origin is refused too.
process.env.MCP_SANDBOX_FULL_ADDRESS = "http://localhost:6274/sandbox";
expect(buildWebServerConfigFromEnv().sandboxPublicUrl).toBeUndefined();
// The app origin is checked against the overridden sandbox's origin.
process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com/sandbox";
process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://sb.example.com";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox");
expect(cfg.appOriginPublicOrigin).toBeUndefined();
} finally {
warnSpy.mockRestore();
}
});
it("leaves an explicit dynamic app-origin port alone", () => {
// 0 means "OS-assigned" for both, so two zeros are not a collision.
process.env.CLIENT_PORT = "6274";