fix(web): move the app origin off 6276, bound the publish route

Copilot review round 2 on #2100, plus a collision the review did not find.

The default app-origin port was 6276 — which is already
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback.
That port is fixed precisely so an OAuth app can pre-register
http://127.0.0.1:6276/oauth/callback, which makes it the one listener here
that cannot move when something takes it first. A running --web would hold
it and break a later --cli/--tui OAuth flow at its registered redirect URI,
and docs/mcp-app-review.md has those two running side by side. This
listener has a documented dynamic fallback, so this listener yields: the
default is now 6278 (6277 skipped — v1's retired proxy port, which the
migration guide tells people to stop forwarding). A test asserts the two
defaults stay distinct.

Review comments:

- POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The
  8MiB check ran after c.req.json() had buffered and parsed the whole
  request, and bounded only `html` — so bulk in `csp`, or in keys the route
  never reads, was fully materialized before the 413.
- `csp` is validated as an HTTP header value and length-bounded. The
  controller hands it to res.writeHead() verbatim, where Node throws
  ERR_INVALID_CHAR synchronously — inside the handler for a later,
  unrelated request, outside this route's error handling.
- The app-origin port now propagates everywhere the other two do: the CLI
  --print-handoff port-forward command (and its test), the Dockerfile's ENV
  and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md.
  Without it an app declaring _meta.ui.domain is unreachable through every
  documented remote workflow.

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:
cliffhall 2026-08-24 00:07:15 -04:00 • committed by GitHub
commit fafc8367b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 211 additions and 73 deletions

View file

@ -25,7 +25,7 @@ v2/main/
│ │ # 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).
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6278).
│ │ # 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

View file

@ -32,11 +32,14 @@ ENV HOST=0.0.0.0 \
DANGEROUSLY_BIND_ALL_INTERFACES=true \
CLIENT_PORT=6274 \
MCP_SANDBOX_PORT=6275 \
MCP_APP_ORIGIN_PORT=6278 \
MCP_AUTO_OPEN_ENABLED=false
# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly.
# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is
# 6275 is the MCP Apps sandbox and 6278 the dedicated app origin (#2056) — two
# further listeners the browser reaches DIRECTLY, so neither works through the
# 6274 publish alone. Both are only needed for the Apps tab (6278 only for an
# App declaring `_meta.ui.domain`), so they are EXPOSEd but publishing them is
# optional — see the Docker section of the root README.
EXPOSE 6274 6275
EXPOSE 6274 6275 6278
# Run as the non-root `node` user the base image ships. The inspector resolves
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`

View file

@ -172,7 +172,7 @@ For the scripted version of the same flow (`--app-info` probe → deep link →
That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a `srcdoc` frame sandboxed without `allow-same-origin`, so its document has an *opaque* origin and every request it makes carries `Origin: null` — which no CORS policy, OAuth callback, or API-key allowlist can admit ([#2056](https://github.com/modelcontextprotocol/inspector/issues/2056)).
Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6276/app-document/<id>` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.)
Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6278/app-document/<id>` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.)
The spec makes `domain`'s format **host-dependent**, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a *request* rather than an address, and answers with a real loopback origin of its own. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app.
@ -477,10 +477,10 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
ghcr.io/modelcontextprotocol/inspector
```
**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.
**And `6278` 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 `6278`); 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 \
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \
ghcr.io/modelcontextprotocol/inspector
```

View file

@ -554,6 +554,7 @@ describe("--print-handoff", () => {
MCP_INSPECTOR_API_TOKEN: "tok123",
CLIENT_PORT: "16274",
MCP_SANDBOX_PORT: "16275",
MCP_APP_ORIGIN_PORT: "16278",
MCP_STORAGE_DIR: "/tmp/inspector-storage",
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
},
@ -574,6 +575,9 @@ describe("--print-handoff", () => {
expect(out.deepLink).toContain("transport=http");
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
// The dedicated app origin (#2056) forwards with the other two — an app
// declaring `_meta.ui.domain` is unreachable without it.
expect(out.portForwardCmd).toContain("--tcp 16278:16278");
expect(out.oauthStatePath).toBe(
join("/tmp/inspector-storage", "oauth.json"),
);

View file

@ -452,6 +452,11 @@ function buildHandoff(
: canonicalUrlHost(host);
const clientPort = process.env.CLIENT_PORT || "6274";
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// The dedicated app origin (#2056). Forwarded alongside the other two: an App
// whose UI resource declares `_meta.ui.domain` is served from this port and
// the browser reaches it DIRECTLY, so a handoff that forwards only 6274/6275
// renders that app from an unreachable origin.
const appOriginPort = process.env.MCP_APP_ORIGIN_PORT || "6278";
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
// can't satisfy the deep-link autoConnect gate.
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
@ -469,7 +474,7 @@ function buildHandoff(
return {
serverUrl: normalizedUrl,
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
note:

View file

@ -155,8 +155,11 @@ Inspector owns no domain infrastructure — it cannot serve
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).
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6278`** — in the same
`627x` family as the web port `6274` and the sandbox port `6275`, so the
three forward together. It is **not** `6276`: that is the fixed loopback
OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and
which therefore cannot move. `6277` is skipped as v1's retired proxy port.
- 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
@ -186,7 +189,7 @@ 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).
**Forward `6278` 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.

View file

@ -57,11 +57,22 @@ 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.
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel.
*
* **Not 6276, and not 6277.** `6276` is already taken, by the fixed loopback
* OAuth callback the CLI and TUI listen on
* (`RUNNER_OAUTH_CALLBACK_DEFAULT_PORT`) — fixed precisely so an OAuth app can
* pre-register `http://127.0.0.1:6276/oauth/callback`, which means it is the
* one port here that CANNOT move when something else takes it first. Binding it
* would let a running `--web` (started at login, holding the port) break a
* later `--cli`/`--tui` OAuth flow at its registered redirect URI — and the
* handoff recipe in `docs/mcp-app-review.md` has those two running side by
* side. Of the two listeners this listener is the one with a documented
* dynamic fallback, so it is the one that yields. `6277` is skipped because it
* was v1's MCP proxy port, which the migration guide tells people to stop
* forwarding; reusing it would make that instruction wrong.
*/
export const DEFAULT_APP_ORIGIN_PORT = 6276;
export const DEFAULT_APP_ORIGIN_PORT = 6278;
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
const MAX_DOCUMENTS = 32;
@ -142,7 +153,7 @@ export interface AppOriginController {
/**
* 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
* `parseInt`'s partial parses (`6278abc`) and the bound keeps an out-of-range
* value from reaching `server.listen`, which throws synchronously.
*/
function parseListenPort(raw: string | undefined): number | undefined {

View file

@ -3489,7 +3489,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
// green while a domain-declaring elicitation app silently loses its real
// origin. Assert it is not merely present but actually reaches the lib.
publishAppDocumentMock.mockResolvedValue(
"http://127.0.0.1:6276/app-document/deadbeef",
"http://127.0.0.1:6278/app-document/deadbeef",
);
renderWithMantine(<App />);
@ -3510,7 +3510,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
html: "<p>app</p>",
csp: "default-src 'none'",
}),
).resolves.toBe("http://127.0.0.1:6276/app-document/deadbeef");
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
expect(publishAppDocumentMock).toHaveBeenCalledWith(
{ html: "<p>app</p>", csp: "default-src 'none'" },
expect.objectContaining({ baseUrl: expect.any(String) }),

View file

@ -498,7 +498,7 @@ describe("createAppBridgeFactory", () => {
);
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6276/app-document/abc");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
@ -517,7 +517,7 @@ describe("createAppBridgeFactory", () => {
const call = bridgeInstances[0].sendSandboxResourceReady.mock
.calls[0][0] as { html: string; src?: string };
expect(call.src).toBe("http://localhost:6276/app-document/abc");
expect(call.src).toBe("http://localhost:6278/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);
@ -530,7 +530,7 @@ describe("createAppBridgeFactory", () => {
])("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");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi
@ -550,7 +550,7 @@ describe("createAppBridgeFactory", () => {
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");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),

View file

@ -27,13 +27,13 @@ describe("publishAppDocument", () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(
jsonResponse({ url: "http://localhost:6276/app-document/abc" }),
jsonResponse({ url: "http://localhost:6278/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(url).toBe("http://localhost:6278/app-document/abc");
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:6274/api/app-document",
expect.objectContaining({

View file

@ -37,13 +37,13 @@ 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" });
.mockReturnValue({ url: "http://localhost:6278/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",
url: "http://localhost:6278/app-document/abc",
});
expect(publish).toHaveBeenCalledWith({
html: "<h1>hi</h1>",
@ -54,7 +54,7 @@ describe("createRemoteApp POST /api/app-document", () => {
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" });
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
expect(res.status).toBe(200);
expect(publish).toHaveBeenCalledWith({
@ -112,6 +112,46 @@ describe("createRemoteApp POST /api/app-document", () => {
expect(publish).not.toHaveBeenCalled();
});
it.each([
["a CR/LF-carrying csp", "default-src 'none'\r\nX-Evil: 1"],
["a NUL-carrying csp", "default-src 'none'\u0000"],
])("rejects %s with 400", async (_label, csp) => {
// The controller hands `csp` to `res.writeHead()` verbatim. Node throws
// ERR_INVALID_CHAR there SYNCHRONOUSLY, inside the handler for a later,
// unrelated request — so accepting one here turns a bad publish into a
// thrown listener rather than a 400 anyone can see.
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: "<p>x</p>", csp }));
expect(res.status).toBe(400);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized csp with 413 — it is retained per document", 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: "<p>x</p>", csp: "a".repeat(8 * 1024 + 1) }),
);
expect(res.status).toBe(413);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized BODY before parsing it", async () => {
// Distinct from the document check below, which only runs once the whole
// request has been buffered and parsed — and only bounds `html`.
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(
post(`{"junk":"${"x".repeat(24 * 1024 * 1024 + 1024)}"}`),
);
expect(res.status).toBe(413);
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>()

View file

@ -21,6 +21,7 @@ import {
resolveAppOriginPort,
appDocumentEmbedders,
} from "../../../../server/app-origin-controller.js";
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
@ -34,6 +35,19 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
});
it("does not default to the runners' fixed OAuth callback port", () => {
// Both listeners are fixed-by-default and both live in the 627x family, so
// the next free number is the obvious pick for each — which is how they
// collided. This one can move (it warns and retries on a dynamic port);
// the OAuth callback cannot, because apps pre-register
// `http://127.0.0.1:<that port>/oauth/callback`. A `--web` holding the
// port would therefore break a later `--cli`/`--tui` OAuth flow, and the
// handoff recipe runs those side by side.
expect(DEFAULT_APP_ORIGIN_PORT).not.toBe(
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
);
});
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
process.env.MCP_APP_ORIGIN_PORT = "9200";
expect(resolveAppOriginPort()).toBe(9200);
@ -41,7 +55,7 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(0);
});
it.each(["6276abc", "70000", "-1", "nope", " "])(
it.each(["6278abc", "70000", "-1", "nope", " "])(
"warns and falls back for an invalid value %j",
(value) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@ -357,7 +371,7 @@ describe("createAppOriginController", () => {
address: () => ({
address: "127.0.0.1",
family: "IPv4",
port: 6276,
port: 6278,
}),
close: (cb?: () => void) => cb?.(),
});
@ -368,11 +382,11 @@ describe("createAppOriginController", () => {
try {
const mod = await import("../../../../server/app-origin-controller.js");
const c = mod.createAppOriginController({
port: 6276,
port: 6278,
host: "127.0.0.1",
});
await c.start();
expect(c.getOrigin()).toBe("http://127.0.0.1:6276");
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
emitter.emit(

View file

@ -303,13 +303,13 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6276", () => {
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6278", () => {
// 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);
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6278);
process.env.MCP_APP_ORIGIN_PORT = "9300";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});

View file

@ -24,6 +24,7 @@ import type { LogEvent } from "pino";
import { Hono } from "hono";
import type { Context, Env, Next } from "hono";
import { streamSSE } from "hono/streaming";
import { bodyLimit } from "hono/body-limit";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import { createTransportNode } from "../../node/transport.js";
import { createProxyFetch } from "../../node/proxyFetch.js";
@ -306,6 +307,41 @@ export interface RemoteServerOptions {
*/
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
/**
* Upper bound on the request BODY, in bytes, enforced by `bodyLimit` *before*
* anything is parsed. {@link MAX_APP_DOCUMENT_CHARS} alone is not enough: it is
* checked after `c.req.json()` has already buffered and parsed the whole
* request, so an unbounded body — or one whose bulk sits in `csp` or in keys
* the route never reads — is fully materialized in memory before being
* rejected. Generous relative to the document bound because JSON escaping
* inflates the payload (`<` stays one byte, but a `"` or a newline becomes
* two, and non-BMP text more), so this must not reject a document the char
* check would accept.
*/
const MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
/**
* Upper bound on the per-app CSP policy string. Unlike the document, this is
* retained for the life of the published entry, and a real policy is a few
* hundred characters — an app pushing more is not describing sources.
*/
const MAX_APP_CSP_CHARS = 8 * 1024;
/**
* Whether a string is safe to emit as an HTTP header VALUE.
*
* The published `csp` is handed to `res.writeHead()` verbatim by the app-origin
* controller. Node validates header values there and throws
* `ERR_INVALID_CHAR` **synchronously inside the request handler** — outside
* this route's error handling, on a request this route is not even part of —
* so a CR/LF or NUL accepted here surfaces later as a thrown listener rather
* than a 400. Restrict to tab plus printable ASCII, which is stricter than RFC
* 9110 field-value grammar and is all a CSP policy needs.
*/
function isHeaderSafeValue(value: string): boolean {
return !/[^\t\x20-\x7e]/.test(value);
}
export interface CreateRemoteAppResult {
/** The Hono app */
app: Hono;
@ -744,7 +780,14 @@ export function createRemoteApp(
* listener on its own port, which is what makes the app's origin real
* instead of opaque.
*/
app.post("/api/app-document", async (c) => {
app.post(
"/api/app-document",
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
bodyLimit({
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
onError: (c) => c.json({ error: "Document too large" }, 413),
}),
async (c) => {
const publish = options.publishAppDocument;
if (!publish) {
return c.json({ error: "App-origin hosting is not available" }, 503);
@ -773,15 +816,25 @@ export function createRemoteApp(
if (csp !== undefined && typeof csp !== "string") {
return c.json({ error: "csp must be a string" }, 400);
}
if (csp !== undefined && !isHeaderSafeValue(csp)) {
// Rejected here rather than sanitized: a policy carrying a control
// character is not a policy with a stray byte in it, it is a caller
// doing something this route has no honest interpretation of.
return c.json({ error: "csp is not a valid header value" }, 400);
}
if (html.length > MAX_APP_DOCUMENT_CHARS) {
return c.json({ error: "Document too large" }, 413);
}
if (csp !== undefined && csp.length > MAX_APP_CSP_CHARS) {
return c.json({ error: "csp 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;

View file

@ -159,11 +159,11 @@ launching.
## 6. Reaching the web inspector remotely (SSH port-forward)
When the backend runs on another host and you want to complete OAuth or
visually inspect a rendered App from your local browser, forward **both**
visually inspect a rendered App from your local browser, forward **all three**
ports:
```bash
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 <remote-host>
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 -L 6278:127.0.0.1:6278 <remote-host>
```
Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
@ -178,6 +178,11 @@ Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
- Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in
an iframe served from that second origin; without it the App frame stays
blank.
- Forward `:6278` (`MCP_APP_ORIGIN_PORT`) too, for an App whose UI resource
declares `_meta.ui.domain` (#2056). That App is served from a *third* origin
the browser loads directly, so without the forward its frame fails to load —
and the backend cannot detect that, since publishing succeeded on its side.
An App that declares no `domain` is unaffected.
- Forward the **same** local port number you bind on the remote — the browser
sends `Origin: http://127.0.0.1:<local-port>` and the backend compares it to
its own port.