feat(web): honor _meta.ui.domain with a dedicated app origin

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>
This commit is contained in:
cliffhall 2026-08-23 23:03:55 -04:00 • committed by GitHub
commit ec40cf5531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1554 additions and 18 deletions

View file

@ -0,0 +1,134 @@
/**
* `POST /api/app-document` (#2056) — the authenticated seam the browser hands a
* wrapped MCP App document back through so the backend can serve it from a
* dedicated origin.
*
* The route owns validation and the two "can't host it" answers; the listener
* that actually serves the bytes is `clients/web/server/app-origin-controller`
* and is tested separately.
*/
import { describe, it, expect, vi } from "vitest";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
function post(body: unknown): Request {
return new Request("http://test/api/app-document", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: typeof body === "string" ? body : JSON.stringify(body),
});
}
function appWith(
publishAppDocument?: (doc: {
html: string;
csp?: string;
}) => { url: string } | null,
) {
return createRemoteApp({
dangerouslyOmitAuth: true,
allowedOrigins: ["http://127.0.0.1:6274"],
initialConfig: { defaultEnvironment: {} },
publishAppDocument,
}).app;
}
describe("createRemoteApp POST /api/app-document", () => {
it("publishes the document and returns the URL", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
const res = await appWith(publish).request(
post({ html: "<h1>hi</h1>", csp: "default-src 'none'" }),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
url: "http://localhost:6276/app-document/abc",
});
expect(publish).toHaveBeenCalledWith({
html: "<h1>hi</h1>",
csp: "default-src 'none'",
});
});
it("accepts a document with no csp", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
expect(res.status).toBe(200);
expect(publish).toHaveBeenCalledWith({
html: "<h1>hi</h1>",
csp: undefined,
});
});
it("answers 503 when the backend supplies no publisher", async () => {
// An embedder with no app-origin listener at all — the browser reads this
// as "no dedicated origin available" and renders the app the default way.
const res = await appWith().request(post({ html: "<h1>hi</h1>" }));
expect(res.status).toBe(503);
});
it("answers 503 when the publisher declines (listener never bound)", async () => {
const res = await appWith(() => null).request(
post({ html: "<h1>hi</h1>" }),
);
expect(res.status).toBe(503);
});
it("rejects a malformed body", async () => {
const res = await appWith(() => ({ url: "http://x/y" })).request(
post("{not json"),
);
expect(res.status).toBe(400);
});
it.each([
["missing html", {}],
["empty html", { html: "" }],
["non-string html", { html: 42 }],
["non-string csp", { html: "<p>x</p>", csp: 42 }],
])("rejects %s with 400", async (_label, body) => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(post(body));
expect(res.status).toBe(400);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized document with 413 rather than pinning it in memory", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(
post({ html: "x".repeat(8 * 1024 * 1024 + 1) }),
);
expect(res.status).toBe(413);
expect(publish).not.toHaveBeenCalled();
});
it("requires the auth token like every other /api route", async () => {
const { app } = createRemoteApp({
authToken: "s3cret",
allowedOrigins: ["http://127.0.0.1:6274"],
initialConfig: { defaultEnvironment: {} },
publishAppDocument: () => ({ url: "http://x/y" }),
});
const unauthorized = await app.request(post({ html: "<p>x</p>" }));
expect(unauthorized.status).toBe(401);
const authorized = await app.request(
new Request("http://test/api/app-document", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-mcp-remote-auth": "Bearer s3cret",
},
body: JSON.stringify({ html: "<p>x</p>" }),
}),
);
expect(authorized.status).toBe(200);
});
});

View file

@ -0,0 +1,375 @@
/**
* The dedicated app origin (#2056): the listener that serves an MCP App whose
* UI resource declares `_meta.ui.domain`, so the app document has a real origin
* and its requests carry a real `Origin` instead of `null`.
*
* The behaviors pinned here are the ones the feature's value rests on: the
* document is reachable at the URL `publish` hands back, it is served with the
* per-app CSP as a real response header plus a `frame-ancestors` naming the
* sandbox proxy, an id nobody published is a 404, and every degradation path
* (no listener, port taken, unusable bind host) leaves the caller able to fall
* back to the default opaque-origin render rather than losing the app.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createServer, type Server } from "node:http";
import { EventEmitter } from "node:events";
import {
createAppOriginController,
DEFAULT_APP_ORIGIN_PORT,
parseDocumentId,
resolveAppOriginPort,
sandboxOriginList,
} from "../../../../server/app-origin-controller.js";
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
afterEach(() => {
if (saved === undefined) delete process.env.MCP_APP_ORIGIN_PORT;
else process.env.MCP_APP_ORIGIN_PORT = saved;
});
it("defaults to the fixed port so it can be forwarded", () => {
delete process.env.MCP_APP_ORIGIN_PORT;
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
});
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
process.env.MCP_APP_ORIGIN_PORT = "9200";
expect(resolveAppOriginPort()).toBe(9200);
process.env.MCP_APP_ORIGIN_PORT = "0";
expect(resolveAppOriginPort()).toBe(0);
});
it.each(["6276abc", "70000", "-1", "nope", " "])(
"warns and falls back for an invalid value %j",
(value) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
process.env.MCP_APP_ORIGIN_PORT = value;
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
// A blank value is "unset", not "invalid" — nothing to warn about.
if (value.trim()) {
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP_APP_ORIGIN_PORT"),
);
} else {
expect(warnSpy).not.toHaveBeenCalled();
}
} finally {
warnSpy.mockRestore();
}
},
);
});
describe("parseDocumentId", () => {
const id = "a".repeat(32);
it("reads the id out of a document path, query string and all", () => {
expect(parseDocumentId(`/app-document/${id}`)).toBe(id);
expect(parseDocumentId(`/app-document/${id}?x=1`)).toBe(id);
expect(parseDocumentId(`/app-document/${id}#frag`)).toBe(id);
});
it.each([
["undefined target", undefined],
["another path", "/sandbox"],
["no id", "/app-document/"],
["a non-hex id", "/app-document/" + "z".repeat(32)],
["a short id", "/app-document/abc"],
["a traversal attempt", "/app-document/../../etc/passwd"],
["a nested path", `/app-document/${id}/extra`],
])("rejects %s", (_label, url) => {
expect(parseDocumentId(url as string | undefined)).toBeNull();
});
});
describe("sandboxOriginList", () => {
it("reduces the sandbox URL to its origin", () => {
expect(sandboxOriginList("http://127.0.0.1:6275/sandbox")).toEqual([
"http://127.0.0.1:6275",
]);
});
it.each([[null], [undefined], [""]])(
"returns undefined for %j so frame-ancestors falls back to loopback",
(value) => {
// Not `[]`: an empty list would make the directive `'none'` and block the
// app frame outright, which is worse than a permissive loopback fallback.
expect(
sandboxOriginList(value as string | null | undefined),
).toBeUndefined();
},
);
});
describe("createAppOriginController", () => {
let controller: ReturnType<typeof createAppOriginController> | null = null;
beforeEach(() => {
controller = null;
});
afterEach(async () => {
await controller?.close();
});
it("serves a published document at the URL it hands back, with the CSP as a header", async () => {
controller = createAppOriginController({
port: 0,
host: "127.0.0.1",
embedderOrigins: ["http://127.0.0.1:6275"],
});
const { url } = await controller.start();
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(controller.getOrigin()).toBe(url);
const published = controller.publish({
html: "<!doctype html><p>app</p>",
csp: "default-src 'none'; connect-src https://api.example.com",
});
expect(published).not.toBeNull();
expect(published!.url.startsWith(`${url}/app-document/`)).toBe(true);
const res = await fetch(published!.url);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
// `nosniff` matters more here than on a page we authored: these bytes are
// server-supplied and untrusted.
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(res.headers.get("cache-control")).toContain("no-store");
// The per-app policy, plus a frame-ancestors naming the sandbox proxy —
// the only page allowed to frame this document.
expect(res.headers.get("content-security-policy")).toBe(
"default-src 'none'; connect-src https://api.example.com; frame-ancestors http://127.0.0.1:6275",
);
await expect(res.text()).resolves.toBe("<!doctype html><p>app</p>");
});
it("serves only frame-ancestors when the document carries no csp", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
const published = controller.publish({ html: "<p>x</p>" })!;
const res = await fetch(published.url);
// No embedderOrigins supplied → the loopback fallback, same as the sandbox
// proxy's own directive.
expect(res.headers.get("content-security-policy")).toBe(
"frame-ancestors http://127.0.0.1:* http://localhost:*",
);
});
it("mints a distinct, unguessable id per document", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
const { url } = await controller.start();
const a = controller.publish({ html: "<p>a</p>" })!;
const b = controller.publish({ html: "<p>b</p>" })!;
expect(a.url).not.toBe(b.url);
expect(a.url.slice(`${url}/app-document/`.length)).toMatch(
/^[0-9a-f]{32}$/,
);
await expect((await fetch(a.url)).text()).resolves.toBe("<p>a</p>");
await expect((await fetch(b.url)).text()).resolves.toBe("<p>b</p>");
});
it("404s an unknown id and any non-document request", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
const { url } = await controller.start();
expect((await fetch(`${url}/app-document/${"b".repeat(32)}`)).status).toBe(
404,
);
expect((await fetch(`${url}/`)).status).toBe(404);
expect((await fetch(`${url}/sandbox`)).status).toBe(404);
});
it("404s a non-GET request to a real document", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
const published = controller.publish({ html: "<p>x</p>" })!;
expect((await fetch(published.url, { method: "POST" })).status).toBe(404);
expect((await fetch(published.url)).status).toBe(200);
});
it("returns null from publish before start, so the caller falls back to srcdoc", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
expect(controller.getOrigin()).toBeNull();
});
it("evicts the oldest document past the cap rather than growing unbounded", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
const first = controller.publish({ html: "<p>first</p>" })!;
// 32 is the cap; publishing 32 more must have pushed the first out.
for (let i = 0; i < 32; i++) {
controller.publish({ html: `<p>${i}</p>` });
}
expect((await fetch(first.url)).status).toBe(404);
});
it("404s a document past its TTL", async () => {
// Fake `Date` ONLY — the TTL is the sole thing that reads it, and faking
// the timer queue too would deadlock the real HTTP round-trip below.
vi.useFakeTimers({ toFake: ["Date"] });
try {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
const published = controller.publish({ html: "<p>x</p>" })!;
expect((await fetch(published.url)).status).toBe(200);
vi.setSystemTime(Date.now() + 61 * 60 * 1000);
expect((await fetch(published.url)).status).toBe(404);
} finally {
vi.useRealTimers();
}
});
it("returns the same handle when start() is called twice", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
const first = await controller.start();
const second = await controller.start();
expect(second).toEqual(first);
});
it("drops published documents on close so nothing is served after teardown", async () => {
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
await controller.start();
const published = controller.publish({ html: "<p>x</p>" })!;
await controller.close();
expect(controller.getOrigin()).toBeNull();
expect(controller.publish({ html: "<p>y</p>" })).toBeNull();
await expect(fetch(published.url)).rejects.toThrow();
controller = null;
});
it("advertises localhost for a wildcard bind", async () => {
controller = createAppOriginController({ port: 0, host: "0.0.0.0" });
const { url } = await controller.start();
// `http://0.0.0.0:PORT` isn't reachable from a browser, but a wildcard bind
// does serve loopback.
expect(url).toMatch(/^http:\/\/localhost:\d+$/);
});
/** Claim a port so a subsequent listen on it fails with EADDRINUSE. */
async function claimPort(): Promise<{
port: number;
release: () => Promise<void>;
}> {
const blocker: Server = createServer();
await new Promise<void>((resolve) =>
blocker.listen(0, "127.0.0.1", () => resolve()),
);
const addr = blocker.address();
const port =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: 0;
expect(port).toBeGreaterThan(0);
return {
port,
release: () =>
new Promise<void>((resolve) => blocker.close(() => resolve())),
};
}
it("falls back to an OS-assigned port when the fixed port is taken, loudly", async () => {
const { port, release } = await claimPort();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
controller = createAppOriginController({ port, host: "127.0.0.1" });
try {
const result = await controller.start();
expect(result.port).toBeGreaterThan(0);
expect(result.port).not.toBe(port);
const published = controller.publish({ html: "<p>x</p>" })!;
expect((await fetch(published.url)).status).toBe(200);
// Loud: an app's backend may allowlist this origin, so a port that
// silently moved is the failure the fixed default exists to prevent.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(`App origin: port ${port} in use`),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP_APP_ORIGIN_PORT"),
);
} finally {
warnSpy.mockRestore();
await release();
}
});
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
// answers null, which the renderer reads as "no dedicated origin".
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
controller = createAppOriginController({
port: 0,
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
});
try {
const result = await controller.start();
expect(result).toEqual({ port: 0, url: "" });
expect(controller.getOrigin()).toBeNull();
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
await expect(controller.close()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
"App origin server error:",
expect.objectContaining({ code: expect.any(String) }),
);
} finally {
errorSpy.mockRestore();
controller = null;
}
});
it("gives up after one retry when the dynamic fallback also fails", async () => {
// Guards the `retriedDynamic` latch. A real `listen(0)` effectively always
// succeeds, so a claimed port only ever produces ONE EADDRINUSE — under
// which this test would pass with the latch deleted. Mock `node:http` so
// every listen fails: without the latch this recurses until timeout.
vi.resetModules();
const listenCalls: number[] = [];
vi.doMock("node:http", async () => {
const actual =
await vi.importActual<typeof import("node:http")>("node:http");
return {
...actual,
createServer: () => {
const emitter = new EventEmitter();
return Object.assign(emitter, {
listen: (p: number) => {
listenCalls.push(p);
setImmediate(() =>
emitter.emit(
"error",
Object.assign(new Error("in use"), { code: "EADDRINUSE" }),
),
);
},
address: () => null,
close: (cb?: () => void) => cb?.(),
});
},
};
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const mod = await import("../../../../server/app-origin-controller.js");
const c = mod.createAppOriginController({
port: DEFAULT_APP_ORIGIN_PORT,
host: "127.0.0.1",
});
const result = await c.start();
expect(result).toEqual({ port: 0, url: "" });
// Exactly two listens: the fixed port, then one dynamic retry.
expect(listenCalls).toEqual([DEFAULT_APP_ORIGIN_PORT, 0]);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("in use. Apps declaring _meta.ui.domain"),
);
} finally {
warnSpy.mockRestore();
errorSpy.mockRestore();
vi.doUnmock("node:http");
vi.resetModules();
}
});
});

View file

@ -123,6 +123,7 @@ describe("startHonoServer autoOpen", () => {
storageDir: undefined,
allowedOrigins: [baseUrl],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: true,

View file

@ -73,6 +73,7 @@ describe("startHonoServer index.html token injection (/ -> /api/*)", () => {
// Allow the same-origin requests the test issues below.
allowedOrigins: [baseUrl],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: false,

View file

@ -26,6 +26,7 @@ const MUTATED_ENV_KEYS = [
"MCP_STORAGE_DIR",
"ALLOWED_ORIGINS",
"MCP_SANDBOX_PORT",
"MCP_APP_ORIGIN_PORT",
"SERVER_PORT",
"MCP_LOG_FILE",
"MCP_AUTO_OPEN_ENABLED",
@ -43,6 +44,7 @@ const baseConfig = (): WebServerConfig => ({
storageDir: undefined,
allowedOrigins: ["http://localhost:6274"],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: false,
@ -301,6 +303,17 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6276", () => {
// The dedicated app origin (#2056). Deliberately NOT sharing the sandbox's
// SERVER_PORT fallback: it is a different listener and must be pinnable
// (and forwardable) on its own.
delete process.env.MCP_APP_ORIGIN_PORT;
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6276);
process.env.MCP_APP_ORIGIN_PORT = "9300";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});
it("sets MCP_STORAGE_DIR when present", () => {
process.env.MCP_STORAGE_DIR = "/tmp/storage";
expect(buildWebServerConfigFromEnv().storageDir).toBe("/tmp/storage");