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
10 changed files with 1554 additions and 18 deletions
|
|
@ -148,6 +148,7 @@ import {
|
|||
import { clearScrollMemory } from "./hooks/useScrollMemory";
|
||||
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
|
||||
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import { publishAppDocument } from "./lib/publishAppDocument";
|
||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||
import {
|
||||
AppElicitationController,
|
||||
|
|
@ -808,9 +809,23 @@ function App() {
|
|||
[],
|
||||
);
|
||||
|
||||
// `_meta.ui.domain` support (#2056): hand a wrapped app document to the
|
||||
// backend so it can serve it from a dedicated origin, giving the app's
|
||||
// requests a real `Origin`. Resolves `null` on any backend that can't, and
|
||||
// the factory then renders the app the default (opaque-origin) way.
|
||||
const publishDocument = useCallback(
|
||||
(doc: { html: string; csp?: string }) =>
|
||||
publishAppDocument(doc, {
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
}),
|
||||
[configBaseUrl],
|
||||
);
|
||||
|
||||
const sandboxBridgeFactory = useMemo(
|
||||
() =>
|
||||
createAppBridgeFactory({
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
getListedResourceMeta,
|
||||
readResource: async (uri) => {
|
||||
|
|
@ -832,7 +847,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient, getListedResourceMeta],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
|
||||
// App-rendered form elicitations (#1854). The controller is created once and
|
||||
|
|
@ -885,6 +900,7 @@ function App() {
|
|||
() =>
|
||||
createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
getListedResourceMeta,
|
||||
readResource: async (uri) => {
|
||||
|
|
@ -903,7 +919,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient, getListedResourceMeta],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
/**
|
||||
* Close the previous client's session and open one for the client being
|
||||
|
|
|
|||
|
|
@ -483,6 +483,141 @@ describe("createAppBridgeFactory", () => {
|
|||
expect(call.html).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
describe("_meta.ui.domain — dedicated origin (#2056)", () => {
|
||||
// An app rendered under the default opaque origin sends `Origin: null`, so
|
||||
// no CORS / OAuth-callback / API-key allowlist can admit it. `domain` is
|
||||
// how a server asks its host for a real one; the Inspector answers with a
|
||||
// URL on its own app-origin listener and passes it as `src`.
|
||||
|
||||
it("publishes the wrapped document and passes its URL as `src`", async () => {
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource("<h1>weather</h1>", {
|
||||
domain: "my-app.example.com",
|
||||
csp: { connectDomains: ["https://api.example.com"] },
|
||||
}),
|
||||
);
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
// What is published is the SAME wrapped document the srcdoc path would
|
||||
// have rendered, plus the policy as a separate string so the backend can
|
||||
// serve it as a real response header.
|
||||
const published = publishAppDocument.mock.calls[0][0];
|
||||
expect(published.html).toContain("<body><h1>weather</h1></body>");
|
||||
expect(published.csp).toContain("connect-src https://api.example.com");
|
||||
|
||||
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
||||
.calls[0][0] as { html: string; src?: string };
|
||||
expect(call.src).toBe("http://localhost:6276/app-document/abc");
|
||||
// `html` still rides along: a proxy that ignores `src` renders the app
|
||||
// rather than a blank frame.
|
||||
expect(call.html).toBe(published.html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty domain", ""],
|
||||
["a whitespace-only domain", " "],
|
||||
["a non-string domain", 42],
|
||||
])("does not take the dedicated path for %s", async (_label, domain) => {
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(uiResource("<h1>x</h1>", { domain })),
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(publishAppDocument).not.toHaveBeenCalled();
|
||||
expect(
|
||||
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
|
||||
).not.toHaveProperty("src");
|
||||
});
|
||||
|
||||
it("never publishes a resource that declares no domain", async () => {
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(publishAppDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to srcdoc, warning, when the backend cannot host the document", async () => {
|
||||
// A backend with no app-origin listener. Losing the real origin degrades
|
||||
// what the app can reach and the developer needs to see why — losing the
|
||||
// app itself would be worse.
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
|
||||
),
|
||||
publishAppDocument: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
||||
.calls[0][0] as { html: string; src?: string };
|
||||
expect(call.src).toBeUndefined();
|
||||
expect(call.html).toContain("<body><h1>x</h1></body>");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("_meta.ui.domain"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to srcdoc when the factory was given no publisher at all", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
|
||||
),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(
|
||||
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
|
||||
).not.toHaveProperty("src");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("_meta.ui.domain"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the shared HOST_CAPABILITIES when echoing the approved sandbox", async () => {
|
||||
// The factory builds a per-app copy ({ ...HOST_CAPABILITIES }) so the
|
||||
// sandbox echo never leaks across apps/renders. Lock that in: after a
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
McpUiDisplayMode,
|
||||
McpUiHostCapabilities,
|
||||
McpUiResourceMeta,
|
||||
McpUiSandboxResourceReadyNotification,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
|
|
@ -108,6 +109,21 @@ export interface AppBridgeFactoryDeps {
|
|||
* there would tell the app something untrue about what its host will do.
|
||||
*/
|
||||
advertiseElicitation?: boolean;
|
||||
/**
|
||||
* Publish a wrapped app document to the backend's dedicated app-origin
|
||||
* listener and resolve with the URL to load it from, or `null` when the
|
||||
* backend can't host one (#2056).
|
||||
*
|
||||
* Only consulted for a UI resource that declares `_meta.ui.domain` — the
|
||||
* spec field by which a server asks its host for a stable, dedicated origin
|
||||
* so its requests carry a real `Origin` instead of `null`. Optional: a
|
||||
* factory without it (and a `null` result from one with it) renders every
|
||||
* app the default way, under an opaque origin.
|
||||
*/
|
||||
publishAppDocument?: (doc: {
|
||||
html: string;
|
||||
csp?: string;
|
||||
}) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,6 +190,20 @@ function extractHtmlAndMeta(
|
|||
throw new Error("UI resource has no text (HTML) content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a UI resource asked for a dedicated origin (#2056).
|
||||
*
|
||||
* Any non-empty `domain` string counts. The spec is explicit that the field's
|
||||
* format is host-dependent — hosts publish their own rules, and the examples it
|
||||
* gives (`{hash}.claudemcpcontent.com`, `www-example-com.oaiusercontent.com`)
|
||||
* are each one host's convention. The Inspector's convention is that the value
|
||||
* is a *request*, not an address: it grants a real loopback origin and does not
|
||||
* try to honor the specific name, which it could not serve anyway.
|
||||
*/
|
||||
function wantsDedicatedOrigin(meta: McpUiResourceMeta | undefined): boolean {
|
||||
return typeof meta?.domain === "string" && meta.domain.trim() !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64-encoded blob resource into bytes for download. Allocates the
|
||||
* backing store explicitly so the return type is `Uint8Array<ArrayBuffer>`
|
||||
|
|
@ -359,10 +389,43 @@ export function createAppBridgeFactory(
|
|||
permissions: approvedPermissions,
|
||||
csp: approvedCsp,
|
||||
};
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
|
||||
const policy = buildSandboxCspPolicy(approvedCsp);
|
||||
const wrapped = wrapSandboxedHtml(html, policy);
|
||||
// `_meta.ui.domain` (#2056): the resource is asking to be served
|
||||
// from a stable, dedicated origin rather than rendered into the
|
||||
// default opaque-origin srcdoc frame, because an opaque origin sends
|
||||
// `Origin: null` and no CORS / OAuth-callback / API-key allowlist can
|
||||
// admit that. The spec makes the field's format host-dependent and
|
||||
// the Inspector owns no domain infrastructure, so what it honors the
|
||||
// request WITH is a real loopback origin — any non-empty domain opts
|
||||
// in, and the string itself is not interpreted (see the app-origin
|
||||
// controller for the host-specific contract).
|
||||
//
|
||||
// A `null` back means the backend has no such listener (an older
|
||||
// backend, or one whose port never bound). Fall back to srcdoc: the
|
||||
// app loses its real origin, which is a degradation the developer
|
||||
// can see and act on — losing the app entirely is not.
|
||||
const dedicated = wantsDedicatedOrigin(meta);
|
||||
const src = dedicated
|
||||
? await deps.publishAppDocument?.({ html: wrapped, csp: policy })
|
||||
: undefined;
|
||||
if (dedicated && !src) {
|
||||
console.warn(
|
||||
"[mcp-app] resource declares _meta.ui.domain but no dedicated app origin is available; rendering under an opaque origin (requests will send `Origin: null`).",
|
||||
);
|
||||
}
|
||||
// `src` is an Inspector-specific extension to the (internal)
|
||||
// sandbox-resource-ready params, which ext-apps types but does not
|
||||
// validate — the bridge forwards the params object verbatim. Declared
|
||||
// as an intersection rather than cast so the extra key stays typed.
|
||||
const readyParams: McpUiSandboxResourceReadyNotification["params"] & {
|
||||
src?: string;
|
||||
} = {
|
||||
html: wrapped,
|
||||
permissions: approvedPermissions,
|
||||
});
|
||||
...(src ? { src } : {}),
|
||||
};
|
||||
await bridge.sendSandboxResourceReady(readyParams);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
console.error(
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -123,6 +123,7 @@ describe("startHonoServer autoOpen", () => {
|
|||
storageDir: undefined,
|
||||
allowedOrigins: [baseUrl],
|
||||
sandboxPort: 0,
|
||||
appOriginPort: 0,
|
||||
sandboxHost: "127.0.0.1",
|
||||
logger: undefined,
|
||||
autoOpen: true,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue