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
20 changed files with 1554 additions and 18 deletions
28
AGENTS.md
28
AGENTS.md
|
|
@ -15,6 +15,34 @@ v2/main/
|
|||
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
|
||||
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
|
||||
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
|
||||
│ │ # app-origin-controller.ts (the DEDICATED APP ORIGIN for
|
||||
│ │ # `_meta.ui.domain` — #2056. The default render hands an
|
||||
│ │ # app's HTML to the proxy as `srcdoc` in a frame with no
|
||||
│ │ # `allow-same-origin`, so the document has an OPAQUE origin
|
||||
│ │ # and every request it makes sends `Origin: null`; no CORS /
|
||||
│ │ # OAuth-callback / API-key allowlist can admit that, which is
|
||||
│ │ # what the spec field exists to fix. `domain` is explicitly
|
||||
│ │ # HOST-DEPENDENT and the Inspector owns no domain
|
||||
│ │ # infrastructure, so it treats any non-empty value as a
|
||||
│ │ # REQUEST, not an address, and answers with a real loopback
|
||||
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6276).
|
||||
│ │ # ONE SHARED origin, path-keyed per document — a real,
|
||||
│ │ # allowlistable origin without a port or DNS name per app,
|
||||
│ │ # and therefore NOT a per-app isolation boundary (two such
|
||||
│ │ # apps share its localStorage/cookies). The inner frame is
|
||||
│ │ # granted `allow-same-origin` on THIS path only, which is
|
||||
│ │ # what makes the origin real; that is not a #1565 regression
|
||||
│ │ # because 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 it is never reachable from the server-supplied
|
||||
│ │ # `sandbox` string, which is still stripped. The browser
|
||||
│ │ # hands the wrapped bytes back through the authenticated
|
||||
│ │ # `POST /api/app-document` (core/mcp/remote/node/server.ts)
|
||||
│ │ # because only it holds the MCP connection. EVERY failure —
|
||||
│ │ # no listener, port never bound, older backend, network
|
||||
│ │ # error — falls back to the srcdoc render with a warning
|
||||
│ │ # rather than blanking the app),
|
||||
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
|
||||
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
|
||||
│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to
|
||||
|
|
|
|||
|
|
@ -466,7 +466,14 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
|
|||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead.
|
||||
**And `6276` if your app declares `_meta.ui.domain`.** That is the spec field a server uses to ask its host for a stable, dedicated origin — without one the app runs at an opaque origin and its requests carry `Origin: null`, which no CORS / OAuth-callback / API-key allowlist can admit. The Inspector answers the request with a real loopback origin on a third listener, `MCP_APP_ORIGIN_PORT` (default `6276`); apps that declare no `domain` never touch it. An app that declares one and can't reach it still renders — at an opaque origin, with a console warning. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the host-specific contract and its isolation trade-offs.
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6276:6276 \
|
||||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Publish each on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. The same holds for the app origin: the URL a published app document is served from is built from the port the container bound, so remap with `-e MCP_APP_ORIGIN_PORT=9001 -p 127.0.0.1:9001:9001` rather than with `-p` alone.
|
||||
|
||||
**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ The `server/` directory holds the Node-only backend:
|
|||
- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner, the default origin allow-list.
|
||||
- **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list).
|
||||
- **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)).
|
||||
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
||||
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`app-origin-controller.ts`** — the dedicated app-origin server for `_meta.ui.domain` (see [MCP App dedicated origins](#mcp-app-dedicated-origins-metauidomain)); **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
||||
- **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here.
|
||||
|
||||
## Development
|
||||
|
|
@ -135,6 +135,58 @@ Three further params extend the deep link to pre-select — and optionally auto-
|
|||
|
||||
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
|
||||
|
||||
## MCP App dedicated origins (`_meta.ui.domain`)
|
||||
|
||||
By default an MCP App is rendered by handing its HTML to the sandbox proxy as
|
||||
`srcdoc`, inside an iframe sandboxed **without** `allow-same-origin`. That is
|
||||
the isolation model from #1565 and it stays the default — but it gives the app
|
||||
document an *opaque* origin, so every request it makes carries `Origin: null`.
|
||||
An app whose backend allowlists origins (CORS, an OAuth callback, an API-key
|
||||
allowlist) can't work that way, which is what the spec's `_meta.ui.domain`
|
||||
exists to solve: a server uses it to ask its host for a stable, dedicated
|
||||
origin.
|
||||
|
||||
**The Inspector's host-specific contract.** The spec makes `domain`'s format
|
||||
host-dependent ("servers MUST consult host-specific documentation"), and the
|
||||
Inspector owns no domain infrastructure — it cannot serve
|
||||
`my-app.example.com`. So it treats the field as a **request, not an address**:
|
||||
|
||||
- Any **non-empty** `domain` string opts the resource in. The value itself is
|
||||
not parsed, matched, or reserved — declare whatever your production host
|
||||
expects.
|
||||
- The Inspector answers with a real HTTP origin on loopback:
|
||||
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6276`** (beside the web
|
||||
port `6274` and the sandbox port `6275`, so the three forward together).
|
||||
- The app document is served from there under an unguessable path, its
|
||||
per-app CSP delivered as a real response **header** (stronger than the
|
||||
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
|
||||
only the sandbox proxy.
|
||||
- The inner iframe is granted `allow-same-origin` on this path **only** —
|
||||
that is what makes the origin real rather than opaque. It is not a
|
||||
weakening of #1565: the listener is on its own port, so the app is
|
||||
cross-origin to both the sandbox proxy and the Inspector, and same-origin
|
||||
policy blocks the reach either way. The proxy refuses the grant outright if
|
||||
the URL's origin equals its own, and the grant is never reachable from the
|
||||
server-supplied `sandbox` string, which is still stripped.
|
||||
|
||||
**It is one shared origin, not one per app.** Every domain-declaring app is
|
||||
served from the same port, keyed by path. That delivers the property the field
|
||||
is *for* — a real, allowlistable origin — without minting a port or a DNS name
|
||||
per app, at the cost of not being a per-app isolation boundary: two such apps
|
||||
share `localStorage`, `sessionStorage`, and cookies for that origin.
|
||||
|
||||
**Every failure falls back rather than blanking the app.** No app-origin
|
||||
listener, a port that never bound, an older backend with no
|
||||
`POST /api/app-document` route, a network error — each renders the app the
|
||||
default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`.
|
||||
Losing the real origin degrades what the app can reach; losing the app itself
|
||||
would be worse.
|
||||
|
||||
**Forward `6276` too** if you need this off loopback (a container, a tunnel).
|
||||
As with the sandbox port, a taken port falls back to an OS-assigned one with a
|
||||
loud warning — the app still renders, but the origin an app's backend was told
|
||||
to allowlist is then wrong, which is what the warning tells you.
|
||||
|
||||
## Theme (`src/theme/`)
|
||||
|
||||
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
|
||||
|
|
|
|||
326
clients/web/server/app-origin-controller.ts
Normal file
326
clients/web/server/app-origin-controller.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* App-origin server: the dedicated origin an MCP App gets when its UI resource
|
||||
* declares `_meta.ui.domain` (#2056).
|
||||
*
|
||||
* ## Why a third listener exists
|
||||
*
|
||||
* By default the Inspector renders an App by handing its HTML to the sandbox
|
||||
* proxy as `srcdoc`, in an iframe sandboxed **without** `allow-same-origin`.
|
||||
* That is the isolation model #1565 chose, and it is the right default — but it
|
||||
* gives the app document an *opaque* origin, so every request it makes carries
|
||||
* `Origin: null`. An app whose backend allowlists origins (CORS, an OAuth
|
||||
* callback, an API-key allowlist) therefore cannot work, which is exactly what
|
||||
* `_meta.ui.domain` exists to solve: the spec lets a server ask its host for a
|
||||
* stable, dedicated origin.
|
||||
*
|
||||
* `domain` is explicitly **host-dependent** — "the format and validation rules
|
||||
* for this field are determined by each host" — and the Inspector owns no
|
||||
* domain infrastructure. What it *can* provide is a real, stable HTTP origin on
|
||||
* loopback. So a UI resource that declares any non-empty `domain` opts into
|
||||
* being served from this listener instead of `srcdoc`, and its requests carry a
|
||||
* real `Origin: http://<host>:<port>`.
|
||||
*
|
||||
* ## One shared origin, not one per domain
|
||||
*
|
||||
* Every domain-declaring app is served from this single port, path-keyed by an
|
||||
* unguessable document id. That is a deliberate trade: it delivers the property
|
||||
* the spec field is *for* (a real, allowlistable origin) without minting a port
|
||||
* — or a DNS name — per app. The consequence is that this origin is **not** a
|
||||
* per-app isolation boundary: two apps served here share `localStorage`,
|
||||
* `sessionStorage`, and cookies for it. They remain isolated from the sandbox
|
||||
* proxy and from the Inspector itself, which are different origins.
|
||||
*
|
||||
* ## Why granting `allow-same-origin` here is not a regression
|
||||
*
|
||||
* The inner frame is sandboxed with `allow-same-origin` only on this path,
|
||||
* which is what makes the origin real rather than opaque. The #1565 rationale —
|
||||
* "the app cannot touch the proxy's DOM, so it cannot bypass its own CSP by
|
||||
* executing in the parent's realm" — still holds, because this listener is on
|
||||
* its own port and is therefore cross-origin to both the proxy and the app
|
||||
* page. Same-origin policy blocks the reach either way.
|
||||
*
|
||||
* The per-app CSP is additionally delivered as a real response **header** here,
|
||||
* which is stronger than the `<meta>` the `srcdoc` path must rely on: a header
|
||||
* applies to the document before any of its bytes are parsed and cannot be
|
||||
* displaced by anything in the app's own markup.
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
|
||||
import { frameAncestorsDirective } from "./sandbox-controller.js";
|
||||
|
||||
/**
|
||||
* The default app-origin listen port — **fixed**, for the same reason
|
||||
* `DEFAULT_SANDBOX_PORT` is: a port that changes per run can never be named in
|
||||
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel. `6276`
|
||||
* sits next to the web (`6274`) and sandbox (`6275`) ports so the three forward
|
||||
* together.
|
||||
*/
|
||||
export const DEFAULT_APP_ORIGIN_PORT = 6276;
|
||||
|
||||
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
||||
const MAX_DOCUMENTS = 32;
|
||||
|
||||
/**
|
||||
* How long a published document stays fetchable. An iframe fetches its `src`
|
||||
* once, so this only has to outlive the gap between publishing and the frame
|
||||
* loading — but a generous window keeps a reload (React StrictMode, a devtools
|
||||
* "reload frame") working rather than blanking the app.
|
||||
*/
|
||||
const DOCUMENT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The sandbox proxy's origin as a one-entry list for
|
||||
* {@link AppOriginControllerOptions.embedderOrigins}, or `undefined` when the
|
||||
* sandbox never bound (its controller degrades to a null URL). Passing
|
||||
* `undefined` is what makes the app-origin `frame-ancestors` fall back to
|
||||
* loopback rather than to `'none'`, which would block the frame outright.
|
||||
*/
|
||||
export function sandboxOriginList(
|
||||
sandboxUrl: string | null | undefined,
|
||||
): string[] | undefined {
|
||||
if (!sandboxUrl) return undefined;
|
||||
try {
|
||||
return [new URL(sandboxUrl).origin];
|
||||
} catch {
|
||||
/* v8 ignore next -- the sandbox controller only ever emits a URL it built
|
||||
itself; an unparseable value would be a bug there, not input. */
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppOriginControllerOptions {
|
||||
/** Port to bind (0 = dynamic). */
|
||||
port: number;
|
||||
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
|
||||
host?: string;
|
||||
/**
|
||||
* The origin(s) allowed to frame a published document — in practice the
|
||||
* sandbox proxy's own origin, since the proxy is what embeds the inner
|
||||
* iframe. Malformed entries are dropped and an empty result falls back to
|
||||
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
|
||||
*/
|
||||
embedderOrigins?: string[];
|
||||
}
|
||||
|
||||
/** A document handed to {@link AppOriginController.publish}. */
|
||||
export interface AppDocument {
|
||||
/** The fully-wrapped HTML the sandbox would otherwise have received. */
|
||||
html: string;
|
||||
/** The per-app CSP policy string, delivered as a response header. */
|
||||
csp?: string;
|
||||
}
|
||||
|
||||
export interface AppOriginController {
|
||||
start(): Promise<{ port: number; url: string }>;
|
||||
close(): Promise<void>;
|
||||
/** The origin this listener serves on, or null when it never bound. */
|
||||
getOrigin(): string | null;
|
||||
/**
|
||||
* Store a document and return the absolute URL the sandbox proxy should load
|
||||
* it from, or `null` when this listener isn't running (the caller then falls
|
||||
* back to the default `srcdoc` path rather than failing the render).
|
||||
*/
|
||||
publish(doc: AppDocument): { url: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
|
||||
* integer in `0`–`65535`. Mirrors `sandbox-controller`'s parser: `^\d+$` rejects
|
||||
* `parseInt`'s partial parses (`6276abc`) and the bound keeps an out-of-range
|
||||
* value from reaching `server.listen`, which throws synchronously.
|
||||
*/
|
||||
function parseListenPort(raw: string | undefined): number | undefined {
|
||||
const v = raw?.trim();
|
||||
if (!v || !/^\d+$/.test(v)) return undefined;
|
||||
const n = parseInt(v, 10);
|
||||
return n <= 65535 ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app-origin port from env: `MCP_APP_ORIGIN_PORT` →
|
||||
* {@link DEFAULT_APP_ORIGIN_PORT}. A set-but-invalid value is warned and falls
|
||||
* through rather than crashing the boot, matching `resolveSandboxPort`.
|
||||
*/
|
||||
export function resolveAppOriginPort(): number {
|
||||
const parsed = parseListenPort(process.env.MCP_APP_ORIGIN_PORT);
|
||||
if (parsed === undefined && process.env.MCP_APP_ORIGIN_PORT?.trim()) {
|
||||
console.warn(
|
||||
`Ignoring invalid MCP_APP_ORIGIN_PORT="${process.env.MCP_APP_ORIGIN_PORT}" (need an integer 0–65535); falling back.`,
|
||||
);
|
||||
}
|
||||
return parsed ?? DEFAULT_APP_ORIGIN_PORT;
|
||||
}
|
||||
|
||||
/** The path a published document is served at, given its id. */
|
||||
const DOC_PATH_PREFIX = "/app-document/";
|
||||
|
||||
/** `/app-document/<id>` → `<id>`, or null for any other request target. */
|
||||
export function parseDocumentId(url: string | undefined): string | null {
|
||||
if (!url || !url.startsWith(DOC_PATH_PREFIX)) return null;
|
||||
// Drop a query string: an app's own URL may carry one and it is not part of
|
||||
// the id. Anything with a further path segment is not a document target.
|
||||
const id = url.slice(DOC_PATH_PREFIX.length).split(/[?#]/)[0];
|
||||
return /^[0-9a-f]{32}$/.test(id ?? "") ? (id ?? null) : null;
|
||||
}
|
||||
|
||||
interface StoredDocument extends AppDocument {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function createAppOriginController(
|
||||
options: AppOriginControllerOptions,
|
||||
): AppOriginController {
|
||||
// Same defaulting rationale as the sandbox controller: never the *name*
|
||||
// `localhost`, which resolves to a single address family and would put this
|
||||
// listener on a different family than the web server (#1951).
|
||||
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
|
||||
let server: Server | null = null;
|
||||
let origin: string | null = null;
|
||||
|
||||
// Insertion-ordered, so the first key is the oldest entry.
|
||||
const documents = new Map<string, StoredDocument>();
|
||||
|
||||
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
||||
|
||||
/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
|
||||
function evictExpired(now: number): void {
|
||||
for (const [id, doc] of documents) {
|
||||
if (doc.expiresAt <= now) documents.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<{ port: number; url: string }> {
|
||||
if (server && origin) {
|
||||
return { port: parseInt(new URL(origin).port, 10), url: origin };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (value: { port: number; url: string }) => {
|
||||
/* v8 ignore next -- defensive double-settle guard: `error` and
|
||||
`listening` are mutually exclusive for a TCP listen. */
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
server = createServer((req, res) => {
|
||||
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
||||
const doc = id ? documents.get(id) : undefined;
|
||||
if (!doc || doc.expiresAt <= Date.now()) {
|
||||
if (id) documents.delete(id);
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
// `nosniff` matters more here than on a page we authored: these
|
||||
// bytes are server-supplied and untrusted.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
// The per-app policy as a real header. It is the same policy baked
|
||||
// into the document's <meta>; delivering both is deliberate —
|
||||
// multiple policies intersect, and two copies of one policy
|
||||
// intersect to itself, so this can only ever be at least as strict.
|
||||
"Content-Security-Policy": doc.csp
|
||||
? `${doc.csp}; ${FRAME_ANCESTORS}`
|
||||
: FRAME_ANCESTORS,
|
||||
});
|
||||
res.end(doc.html);
|
||||
});
|
||||
|
||||
// Same fallback as the sandbox controller: a fixed default port can
|
||||
// collide (a second Inspector), and losing the listener would take
|
||||
// dedicated-origin rendering down. Retry once on an OS-assigned port,
|
||||
// loudly — whoever pinned the port to forward it needs to know.
|
||||
let retriedDynamic = false;
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
if (!retriedDynamic && port !== 0) {
|
||||
retriedDynamic = true;
|
||||
console.warn(
|
||||
`App origin: port ${port} in use; falling back to an OS-assigned port. ` +
|
||||
`Apps declaring _meta.ui.domain will still render, but the origin ` +
|
||||
`they are served from is no longer predictable — set ` +
|
||||
`MCP_APP_ORIGIN_PORT to a free one if your app's backend ` +
|
||||
`allowlists it.`,
|
||||
);
|
||||
server!.listen(0, host);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`App origin: port ${port || "dynamic"} in use. Apps declaring _meta.ui.domain will fall back to an opaque origin.`,
|
||||
);
|
||||
} else {
|
||||
console.error("App origin server error:", err);
|
||||
}
|
||||
// Degrade rather than reject: `publish` then returns null and the
|
||||
// renderer keeps working on the default srcdoc path.
|
||||
server = null;
|
||||
settle({ port: 0, url: "" });
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const addr = server!.address();
|
||||
const actualPort =
|
||||
typeof addr === "object" && addr !== null && "port" in addr
|
||||
? addr.port
|
||||
: /* v8 ignore next -- unreachable for a TCP listen; `address()`
|
||||
returns AddressInfo. */
|
||||
(addr as unknown as number);
|
||||
// A wildcard bind isn't reachable as `http://0.0.0.0:PORT`, but it
|
||||
// does serve loopback — advertise `localhost` there, and otherwise
|
||||
// the same canonical host the origin allow-list emits.
|
||||
const canonicalHost = canonicalUrlHost(host);
|
||||
const urlHost = isAllInterfacesHost(canonicalHost)
|
||||
? "localhost"
|
||||
: canonicalHost;
|
||||
origin = `http://${urlHost}:${actualPort}`;
|
||||
settle({ port: actualPort, url: origin });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
documents.clear();
|
||||
if (!server) return;
|
||||
return new Promise((resolve) => {
|
||||
server!.close(() => {
|
||||
server = null;
|
||||
origin = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getOrigin(): string | null {
|
||||
return origin;
|
||||
},
|
||||
|
||||
publish(doc: AppDocument): { url: string } | null {
|
||||
if (!origin) return null;
|
||||
const now = Date.now();
|
||||
evictExpired(now);
|
||||
// Bound the map even if nothing has expired: drop the oldest entry. An
|
||||
// evicted document's frame has long since loaded, and the alternative is
|
||||
// an unbounded store fed by every app the user opens in a session.
|
||||
while (documents.size >= MAX_DOCUMENTS) {
|
||||
const oldest = documents.keys().next();
|
||||
/* v8 ignore next -- `size >= MAX_DOCUMENTS` guarantees a first key. */
|
||||
if (oldest.done) break;
|
||||
documents.delete(oldest.value);
|
||||
}
|
||||
const id = randomBytes(16).toString("hex");
|
||||
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS });
|
||||
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -70,7 +70,21 @@ const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
|
|||
* header can't be corrupted.
|
||||
*/
|
||||
export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
|
||||
const valid = (allowedOrigins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
return frameAncestorsDirective(allowedOrigins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `frame-ancestors` directive from a caller-supplied origin list,
|
||||
* dropping malformed entries ({@link CSP_HOST_SOURCE}) and falling back to
|
||||
* {@link LOOPBACK_FRAME_ANCESTORS} when nothing valid remains.
|
||||
*
|
||||
* Shared with the app-origin server (#2056), which restricts a published app
|
||||
* document to being framed by the sandbox proxy. Same grammar, same failure
|
||||
* mode — a directive whose only source is invalid degrades to `'none'` and
|
||||
* blocks the frame — so the two derive it here rather than each rolling one.
|
||||
*/
|
||||
export function frameAncestorsDirective(origins?: string[]): string {
|
||||
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
||||
return `frame-ancestors ${sources.join(" ")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { Context } from "hono";
|
|||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
sandboxOriginList,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
|
|
@ -44,6 +48,15 @@ export async function startHonoServer(
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
// (#2056). Started after the sandbox because its `frame-ancestors` names the
|
||||
// sandbox proxy's origin — the proxy is what embeds the app frame.
|
||||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
|
||||
});
|
||||
await appOriginController.start();
|
||||
|
||||
const resolvedAuthToken =
|
||||
config.authToken ||
|
||||
|
|
@ -65,6 +78,7 @@ export async function startHonoServer(
|
|||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
publishAppDocument: (doc) => appOriginController.publish(doc),
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
// The startup value above is what the banner printed; the route
|
||||
|
|
@ -159,6 +173,7 @@ export async function startHonoServer(
|
|||
async close(): Promise<void> {
|
||||
await closeApi();
|
||||
await sandboxController.close();
|
||||
await appOriginController.close();
|
||||
if ("closeAllConnections" in httpServer) {
|
||||
httpServer.closeAllConnections();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { Plugin } from "vite";
|
|||
// spurious "could not resolve" warnings during build.
|
||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
sandboxOriginList,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
|
|
@ -67,6 +71,15 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
// (#2056). Started after the sandbox because its `frame-ancestors` names
|
||||
// the sandbox proxy's origin — the proxy is what embeds the app frame.
|
||||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
|
||||
});
|
||||
await appOriginController.start();
|
||||
// Resolved before the API is built so `/api/config` and the banner
|
||||
// report the same store (the selection itself is cached process-wide).
|
||||
const secretStorage = await getSecretStorageInfo();
|
||||
|
|
@ -84,6 +97,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
publishAppDocument: (doc) => appOriginController.publish(doc),
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
// The startup value above is what the banner printed; the route
|
||||
|
|
@ -103,6 +117,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
server.close = async () => {
|
||||
await closeApi();
|
||||
await sandboxController.close();
|
||||
await appOriginController.close();
|
||||
return originalClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { SecretStorageInfo } from "../../../core/auth/secret-storage-info.t
|
|||
import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts";
|
||||
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
import { resolveAppOriginPort } from "./app-origin-controller.js";
|
||||
import { resolveBindHostname } from "./resolve-bind-host.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
|
|
@ -66,6 +67,13 @@ export interface WebServerConfig {
|
|||
/** Sandbox port (0 = dynamic). */
|
||||
sandboxPort: number;
|
||||
sandboxHost: string;
|
||||
/**
|
||||
* Port for the dedicated app origin (#2056) — the listener that serves an
|
||||
* MCP App declaring `_meta.ui.domain` so its requests carry a real `Origin`
|
||||
* instead of `null`. `0` = dynamic. Bound on {@link sandboxHost}, the same
|
||||
* address the sandbox uses.
|
||||
*/
|
||||
appOriginPort: number;
|
||||
logger: Logger | undefined;
|
||||
/** When true, open browser after server starts. */
|
||||
autoOpen: boolean;
|
||||
|
|
@ -369,6 +377,7 @@ export function buildWebServerConfig(
|
|||
"");
|
||||
|
||||
const sandboxPort = resolveSandboxPort();
|
||||
const appOriginPort = resolveAppOriginPort();
|
||||
|
||||
let logger: Logger | undefined;
|
||||
if (process.env.MCP_LOG_FILE) {
|
||||
|
|
@ -439,6 +448,7 @@ export function buildWebServerConfig(
|
|||
: defaultAllowedOrigins(hostname, port),
|
||||
sandboxPort,
|
||||
sandboxHost: hostname,
|
||||
appOriginPort,
|
||||
logger,
|
||||
autoOpen: resolveAutoOpen(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!-- Isolation model: the inner iframe is sandboxed WITHOUT
|
||||
<!-- Isolation model: by default the inner iframe is sandboxed WITHOUT
|
||||
`allow-same-origin`, so the untrusted app runs under an opaque
|
||||
("null") origin. That is the structural boundary — the app cannot
|
||||
touch this proxy's DOM, window, or cookies, so it cannot bypass its
|
||||
|
|
@ -15,6 +15,18 @@
|
|||
resource-ready message's `sandbox` field; `allow-same-origin` is
|
||||
always stripped from a server-supplied value.
|
||||
|
||||
Dedicated-origin exception (#2056): an app whose UI resource declares
|
||||
`_meta.ui.domain` is instead served from the host's own app-origin
|
||||
listener, handed to us as the resource-ready message's `src`. That
|
||||
frame IS granted `allow-same-origin` — an opaque origin sends
|
||||
`Origin: null`, which defeats the CORS / OAuth-callback / API-key
|
||||
allowlists the field exists to support. The boundary is preserved by
|
||||
the origin itself rather than by opacity: the listener is on its own
|
||||
port, so the app is cross-origin to this proxy and to the host, and
|
||||
the grant is refused outright if the URL's origin equals this page's.
|
||||
The grant is host-driven — it is never reachable from the server's
|
||||
own `sandbox` string, which is still stripped.
|
||||
|
||||
Network/resource egress from the inner document is constrained by a
|
||||
Content-Security-Policy that the HOST builds and bakes into the
|
||||
delivered HTML before it reaches this proxy (see
|
||||
|
|
@ -85,13 +97,21 @@
|
|||
// This is the origin we expect all parent messages to come from.
|
||||
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
||||
|
||||
// The inner iframe is sandboxed without allow-same-origin, so its
|
||||
// document has an opaque origin and every postMessage from it arrives
|
||||
// The inner iframe is sandboxed without allow-same-origin by default, so
|
||||
// its document has an opaque origin and every postMessage from it arrives
|
||||
// with event.origin === "null" (the literal string). That value is
|
||||
// checked below as a defense-in-depth assertion; the actual identity
|
||||
// proof is `event.source === inner.contentWindow`, which a sibling
|
||||
// frame cannot forge.
|
||||
const INNER_ORIGIN = "null";
|
||||
//
|
||||
// The dedicated-origin path (#2056) is the one exception: when the host
|
||||
// supplies a `src` on its own app-origin listener, the frame is granted
|
||||
// allow-same-origin so the app has a REAL origin — which is the entire
|
||||
// point, since an opaque origin sends `Origin: null` and defeats any
|
||||
// CORS / OAuth-callback / API-key allowlist the app's backend applies.
|
||||
// `innerOrigin` is then that URL's origin, and the assertion above still
|
||||
// holds against it.
|
||||
let innerOrigin = "null";
|
||||
|
||||
// Security self-test: verify iframe isolation is working correctly.
|
||||
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
||||
|
|
@ -158,7 +178,37 @@
|
|||
}
|
||||
|
||||
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
||||
const { html, sandbox, permissions } = event.data.params;
|
||||
const { html, sandbox, permissions, src } = event.data.params;
|
||||
// The host asked for the dedicated-origin path: it published the
|
||||
// wrapped document to its own app-origin listener and is handing
|
||||
// us the URL. Only an absolute http(s) URL is honored — anything
|
||||
// else (a relative path, a `javascript:` or `data:` URL) falls
|
||||
// through to the srcdoc path below rather than being navigated to.
|
||||
let srcUrl = null;
|
||||
if (typeof src === "string") {
|
||||
try {
|
||||
const parsed = new URL(src);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
srcUrl = parsed;
|
||||
} else {
|
||||
console.warn("[Sandbox] ignoring non-http(s) src:", src);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Sandbox] ignoring unparseable src:", src);
|
||||
}
|
||||
}
|
||||
// The app origin MUST differ from this proxy's own — otherwise
|
||||
// granting allow-same-origin below would put the untrusted app in
|
||||
// this realm, exactly the escape the double-iframe model exists to
|
||||
// prevent. Refuse rather than degrade quietly: this is a host
|
||||
// misconfiguration, not app input.
|
||||
if (srcUrl && srcUrl.origin === window.location.origin) {
|
||||
console.error(
|
||||
"[Sandbox] refusing a src on the proxy's own origin:",
|
||||
srcUrl.origin,
|
||||
);
|
||||
srcUrl = null;
|
||||
}
|
||||
if (typeof sandbox === "string") {
|
||||
// Never grant allow-same-origin even if the server asks for it —
|
||||
// that would let the untrusted app reach this proxy's DOM and
|
||||
|
|
@ -179,13 +229,34 @@
|
|||
}
|
||||
inner.setAttribute("sandbox", safeTokens.join(" "));
|
||||
}
|
||||
if (srcUrl) {
|
||||
// Re-grant what the strip above removes unconditionally. Safe
|
||||
// here and only here: the document is served from a DIFFERENT
|
||||
// origin than this proxy (asserted above), so same-origin policy
|
||||
// still denies it this realm's DOM — it gains its own origin,
|
||||
// not ours.
|
||||
const tokens = (inner.getAttribute("sandbox") || "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (!tokens.some((t) => t.toLowerCase() === "allow-same-origin")) {
|
||||
tokens.push("allow-same-origin");
|
||||
}
|
||||
inner.setAttribute("sandbox", tokens.join(" "));
|
||||
}
|
||||
// Set Permission Policy allow attribute if permissions are requested
|
||||
const allowAttribute = buildAllowAttribute(permissions);
|
||||
if (allowAttribute) {
|
||||
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
|
||||
inner.setAttribute("allow", allowAttribute);
|
||||
}
|
||||
if (typeof html === "string") {
|
||||
if (srcUrl) {
|
||||
// Navigate to the host's app-origin URL. The per-app CSP is
|
||||
// delivered as a real response header there (stronger than the
|
||||
// <meta> the srcdoc path relies on), so no wrapping applies here
|
||||
// — this proxy still never parses the untrusted bytes.
|
||||
innerOrigin = srcUrl.origin;
|
||||
inner.src = srcUrl.href;
|
||||
} else if (typeof html === "string") {
|
||||
// The host has already wrapped the app's HTML in a fixed shell
|
||||
// whose first <head> child is the CSP <meta>; assign it verbatim.
|
||||
// srcdoc is the only delivery path that works without
|
||||
|
|
@ -198,7 +269,16 @@
|
|||
}
|
||||
} else {
|
||||
if (inner && inner.contentWindow) {
|
||||
inner.contentWindow.postMessage(event.data, "*");
|
||||
// Target the inner frame's real origin on the dedicated-origin
|
||||
// path, so a frame that navigated itself elsewhere stops
|
||||
// receiving host messages. The opaque default has no targetable
|
||||
// origin ("null" is not a valid targetOrigin), so it keeps "*" —
|
||||
// safe there because the frame is `srcdoc` and cannot navigate
|
||||
// to another origin's document without allow-top-navigation.
|
||||
inner.contentWindow.postMessage(
|
||||
event.data,
|
||||
innerOrigin === "null" ? "*" : innerOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.source === inner.contentWindow) {
|
||||
|
|
@ -207,12 +287,12 @@
|
|||
// sandbox means its origin serialises as the literal string "null";
|
||||
// assert that as defense-in-depth so a future change that
|
||||
// accidentally re-grants allow-same-origin is caught here.
|
||||
if (event.origin !== INNER_ORIGIN) {
|
||||
if (event.origin !== innerOrigin) {
|
||||
console.error(
|
||||
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
|
||||
event.origin,
|
||||
"expected opaque:",
|
||||
INNER_ORIGIN,
|
||||
"expected:",
|
||||
innerOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,6 +256,23 @@ export interface RemoteServerOptions {
|
|||
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
|
||||
sandboxUrl?: string;
|
||||
|
||||
/**
|
||||
* Publish a fully-wrapped MCP App document to the backend's dedicated
|
||||
* app-origin listener and return the URL to load it from (#2056). When set,
|
||||
* `POST /api/app-document` is served; when absent — or when it returns
|
||||
* `null`, meaning the listener never bound — the route answers 503 and the
|
||||
* browser falls back to the default opaque-origin `srcdoc` render.
|
||||
*
|
||||
* Supplied by the web backends only. It is an option rather than a route
|
||||
* built here because the listener is a `clients/web/server` concern; this
|
||||
* module owns nothing but the authenticated seam the browser reaches it
|
||||
* through.
|
||||
*/
|
||||
publishAppDocument?: (doc: {
|
||||
html: string;
|
||||
csp?: string;
|
||||
}) => { url: string } | null;
|
||||
|
||||
/** Initial config for GET /api/config. Caller must pass this (e.g. from webServerConfigToInitialPayload(config)). */
|
||||
initialConfig: InitialConfigPayload;
|
||||
|
||||
|
|
@ -280,6 +297,15 @@ export interface RemoteServerOptions {
|
|||
secretStorageResolver?: () => Promise<SecretStorageInfo | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on a single MCP App document accepted by `POST /api/app-document`
|
||||
* (#2056). Counted in UTF-16 code units, which is what `String.length` gives —
|
||||
* a loose but cheap proxy for bytes, and it only has to keep a runaway or
|
||||
* hostile payload from being pinned in the backend's memory. Generous: a real
|
||||
* app inlines its own scripts and styles into this one document.
|
||||
*/
|
||||
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
||||
|
||||
export interface CreateRemoteAppResult {
|
||||
/** The Hono app */
|
||||
app: Hono;
|
||||
|
|
@ -707,6 +733,45 @@ export function createRemoteApp(
|
|||
return c.json(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hand the backend a wrapped MCP App document and get back the URL its
|
||||
* dedicated origin serves it from (#2056).
|
||||
*
|
||||
* The browser is what holds the document — it read the `ui://` resource over
|
||||
* the MCP connection and wrapped it — so the only way it can reach a real
|
||||
* HTTP origin is to hand the bytes back. This route is the authenticated
|
||||
* seam for that; the returned URL is unguessable and is served by a separate
|
||||
* listener on its own port, which is what makes the app's origin real
|
||||
* instead of opaque.
|
||||
*/
|
||||
app.post("/api/app-document", async (c) => {
|
||||
const publish = options.publishAppDocument;
|
||||
if (!publish) {
|
||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||
}
|
||||
let body: { html?: unknown; csp?: unknown };
|
||||
try {
|
||||
body = (await c.req.json()) as { html?: unknown; csp?: unknown };
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const { html, csp } = body;
|
||||
if (typeof html !== "string" || html === "") {
|
||||
return c.json({ error: "Missing html" }, 400);
|
||||
}
|
||||
if (csp !== undefined && typeof csp !== "string") {
|
||||
return c.json({ error: "csp must be a string" }, 400);
|
||||
}
|
||||
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
||||
return c.json({ error: "Document too large" }, 413);
|
||||
}
|
||||
const published = publish({ html, csp });
|
||||
if (!published) {
|
||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||
}
|
||||
return c.json(published);
|
||||
});
|
||||
|
||||
app.post("/api/mcp/connect", async (c) => {
|
||||
let body: RemoteConnectRequest;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue