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:
parent
5de0b8a5ca
commit
ec40cf5531
2 changed files with 1554 additions and 18 deletions
119
clients/web/src/lib/publishAppDocument.test.ts
Normal file
119
clients/web/src/lib/publishAppDocument.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* `publishAppDocument` (#2056) — the browser half of dedicated-origin hosting.
|
||||
*
|
||||
* The contract worth pinning is the failure behavior: every way this can fail
|
||||
* must resolve `null` rather than throw, because the caller's response to all
|
||||
* of them is the same (render the app the default way), and a throw there
|
||||
* would cost the user the app itself.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { publishAppDocument } from "./publishAppDocument";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("publishAppDocument", () => {
|
||||
it("POSTs the document with the auth header and returns the URL", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ url: "http://localhost:6276/app-document/abc" }),
|
||||
);
|
||||
const url = await publishAppDocument(
|
||||
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
||||
{ baseUrl: "http://localhost:6274", authToken: "tok", fetchFn },
|
||||
);
|
||||
expect(url).toBe("http://localhost:6276/app-document/abc");
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"http://localhost:6274/api/app-document",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-mcp-remote-auth": "Bearer tok",
|
||||
},
|
||||
body: JSON.stringify({ html: "<p>app</p>", csp: "default-src 'none'" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the auth header when there is no token, and trims a trailing slash", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(jsonResponse({ url: "http://x/y" }));
|
||||
await publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://localhost:6274/", fetchFn },
|
||||
);
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"http://localhost:6274/api/app-document",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a 503 (no app-origin listener)", jsonResponse({}, 503)],
|
||||
["a 404 (backend predating the route)", jsonResponse({}, 404)],
|
||||
["a body with no url", jsonResponse({})],
|
||||
["a body whose url is not a string", jsonResponse({ url: 42 })],
|
||||
["a body whose url is empty", jsonResponse({ url: "" })],
|
||||
])("resolves null for %s", async (_label, response) => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(response);
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves null on a network error rather than rejecting", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockRejectedValue(new Error("offline"));
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves null when the response body is not JSON", async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error("not json");
|
||||
},
|
||||
} as unknown as Response);
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to globalThis.fetch when none is injected", async () => {
|
||||
const spy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(jsonResponse({ url: "http://x/y" }));
|
||||
try {
|
||||
await expect(
|
||||
publishAppDocument({ html: "<p>app</p>" }, { baseUrl: "http://b" }),
|
||||
).resolves.toBe("http://x/y");
|
||||
expect(spy).toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
67
clients/web/src/lib/publishAppDocument.ts
Normal file
67
clients/web/src/lib/publishAppDocument.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Publish a wrapped MCP App document to the backend's dedicated app-origin
|
||||
* listener and get back the URL to load it from (#2056).
|
||||
*
|
||||
* This is `lib`, not `utils`: it does network I/O against the authenticated
|
||||
* `/api/*` surface.
|
||||
*
|
||||
* ## Why the browser hands the bytes back
|
||||
*
|
||||
* The app's HTML arrives over the MCP connection, which only the browser
|
||||
* holds — the backend has no client of its own to read the `ui://` resource
|
||||
* with. So the one way an app can be served from a real HTTP origin is for the
|
||||
* browser to POST the document it already wrapped. The backend stores it under
|
||||
* an unguessable id and serves it from a separate listener on its own port,
|
||||
* which is what makes the app's origin real instead of opaque.
|
||||
*
|
||||
* Only apps whose UI resource declares `_meta.ui.domain` take this path; every
|
||||
* other app keeps the default `srcdoc` render under an opaque origin.
|
||||
*/
|
||||
|
||||
/** Backend response shape for a successful publish. */
|
||||
interface PublishResponse {
|
||||
url?: unknown;
|
||||
}
|
||||
|
||||
export interface PublishAppDocumentOptions {
|
||||
/** Base URL of the backend (typically `window.location.origin`). */
|
||||
baseUrl: string;
|
||||
/** Optional auth token for the `x-mcp-remote-auth` header. */
|
||||
authToken?: string;
|
||||
/** Fetch function to use (default: `globalThis.fetch`). Useful in tests. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the document and return the absolute URL it is served from, or `null`
|
||||
* when the backend cannot host it.
|
||||
*
|
||||
* Never throws. Every failure — an old backend with no route, a backend with no
|
||||
* app-origin listener (503), a network error, a malformed body — is a `null`,
|
||||
* because the caller's response to all of them is the same: render the app the
|
||||
* default way. Losing the dedicated origin degrades what the app can reach; it
|
||||
* must not cost the user the app itself.
|
||||
*/
|
||||
export async function publishAppDocument(
|
||||
doc: { html: string; csp?: string },
|
||||
opts: PublishAppDocumentOptions,
|
||||
): Promise<string | null> {
|
||||
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
||||
const base = opts.baseUrl.replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (opts.authToken) headers["x-mcp-remote-auth"] = `Bearer ${opts.authToken}`;
|
||||
try {
|
||||
const res = await doFetch(`${base}/api/app-document`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as PublishResponse;
|
||||
return typeof body.url === "string" && body.url ? body.url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue