inspector/clients/web/src/test/integration/mcp/remote/app-document-route.test.ts
cliffhall ec40cf5531
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>
2026-08-24 08:45:40 -04:00

134 lines
4.6 KiB
TypeScript

/**
* `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);
});
});