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>
134 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|