Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
This commit is contained in:
cliffhall 2026-08-24 21:26:59 -04:00
commit baf4fe954f
12 changed files with 9048 additions and 1271 deletions

View file

@ -389,7 +389,7 @@ describe("InspectorClient coverage backfill", () => {
serverSettings: {
headers: [],
env: [],
metadata: [{ key: "x-test", value: "1" }],
metadata: { "x-test": "1" },
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -655,7 +655,7 @@ describe("InspectorClient coverage backfill", () => {
serverSettings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 1,
requestTimeout: 0,
taskTtl: 0,

View file

@ -506,7 +506,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -535,7 +535,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -586,7 +586,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -53,7 +53,9 @@ import type {
MessageEntry,
ConnectionStatus,
FetchRequestEntryBase,
RequestMetadata,
} from "@inspector/core/mcp/types.js";
import type { InspectorLogger } from "@inspector/core/logging/logger.js";
import type { JsonValue } from "@inspector/core/json/jsonUtils.js";
import type {
TypedEvent,
@ -164,7 +166,7 @@ function settleInFlight(call: Promise<unknown>): InFlightCall {
/** Get all resources from the client via listResources() (paginates if needed). */
async function getAllResources(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<Resource[]> {
const collected: Resource[] = [];
let cursor: string | undefined;
@ -180,7 +182,7 @@ async function getAllResources(
/** Get all resource templates via listResourceTemplates() (paginates if needed). */
async function getAllResourceTemplates(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceTemplate[]> {
const collected: ResourceTemplate[] = [];
let cursor: string | undefined;
@ -196,7 +198,7 @@ async function getAllResourceTemplates(
/** Get all prompts via listPrompts() (paginates if needed). */
async function getAllPrompts(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<Prompt[]> {
const collected: Prompt[] = [];
let cursor: string | undefined;
@ -386,7 +388,7 @@ describe("InspectorClient", () => {
serverSettings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 50,
requestTimeout: 0,
taskTtl: 0,
@ -756,7 +758,7 @@ describe("InspectorClient", () => {
const initial = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -1372,6 +1374,256 @@ describe("InspectorClient", () => {
messageLogState.destroy();
});
it("sends a structured default _meta value as JSON, not as a string (#1910)", async () => {
// The whole point of #1910: a `_meta` value may be an object, an array,
// a number, a boolean or `null`. The Inspector used to type this map as
// `Record<string, string>`, so a nested payload could only be sent by
// stringifying it — which is a different wire message than the one the
// user configured.
const structured = {
trace: { id: "abc123", sampled: true, hops: [1, 2] },
retries: 3,
beta: false,
unset: null,
};
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
defaultMetadata: structured,
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const listToolsReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(listToolsReq).toBeDefined();
expect(metaOf(listToolsReq!)).toMatchObject(structured);
messageLogState.destroy();
});
it.each([
["a float", 3.5],
["an object", { id: 1 }],
["an array", [1]],
["null", null],
["a boolean", true],
// Past `Number.MAX_SAFE_INTEGER` the float64 value is no longer the
// integer it was written as, and zod 4's `.int()` rejects it — so
// `Number.isInteger` is too loose a check here.
["an unsafe integer", Number.MAX_SAFE_INTEGER + 1],
])(
"drops a reserved progressToken that is %s rather than sending it",
async (_label, badToken) => {
// Every other `_meta` value may be any JSON (#1910), but the spec
// constrains `progressToken` to `string | integer`
// (`ProgressTokenSchema`). Sending a bad one would make a conforming
// server reject the whole request, so the member is dropped — the key
// is optional, so what goes out is still well-formed.
//
// `progress: false` is what makes this observable. With progress on
// (the default) the SDK sets `onprogress` and stamps its own message
// id over `_meta.progressToken`, so a caller's value never reaches the
// wire either way; with it off nothing overwrites the member and a bad
// one would ship.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
progress: false,
defaultMetadata: { progressToken: badToken, keep: "me" },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const listToolsReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(listToolsReq).toBeDefined();
const meta = metaOf(listToolsReq!);
expect(meta).not.toHaveProperty("progressToken");
// Only the offending member is dropped, not the whole payload.
expect(meta.keep).toBe("me");
messageLogState.destroy();
},
);
it.each([
["an array", [1]],
["a string", "t1"],
["an object with a non-string taskId", { taskId: 5 }],
])(
"drops a related-task member that is %s, keeping its siblings",
async (_label, badRelatedTask) => {
// `progressToken` is not the only reserved member: the pinned SDK also
// constrains `io.modelcontextprotocol/related-task` to
// `{ taskId: string }`. A bad one makes a conforming server reject the
// whole request, so it is dropped the same way.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
progress: false,
defaultMetadata: {
"io.modelcontextprotocol/related-task": badRelatedTask,
keep: "me",
},
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const req = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
const meta = metaOf(req!);
expect(meta).not.toHaveProperty("io.modelcontextprotocol/related-task");
expect(meta.keep).toBe("me");
messageLogState.destroy();
},
);
it("keeps a well-formed related-task member", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
progress: false,
defaultMetadata: {
"io.modelcontextprotocol/related-task": { taskId: "task-1" },
},
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const req = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(metaOf(req!)["io.modelcontextprotocol/related-task"]).toEqual({
taskId: "task-1",
});
messageLogState.destroy();
});
it("warns with the rejected progressToken's type, never its value", async () => {
// This logger is persisted by real clients (the TUI writes it to
// `~/.mcp-inspector/auth.log`), and an invalid `progressToken` can now be
// an object holding credentials — so the warning must not echo it.
const warn = vi.fn();
const noop = () => {};
const logger: InspectorLogger = {
level: "warn",
fatal: noop,
error: noop,
warn,
info: noop,
debug: noop,
trace: noop,
silent: noop,
child: () => logger,
};
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode, logger },
progress: false,
defaultMetadata: {
progressToken: { accessToken: "sk-live-should-not-appear" },
},
},
);
await client.connect();
await client.listTools();
// The offending key travels in the log's bindings, not in the message —
// the message is shared by every reserved member the sanitizer drops.
const call = warn.mock.calls.find(
(c) => (c[0] as { key?: string })?.key === "progressToken",
);
expect(call).toBeDefined();
expect(call![0]).toEqual({ key: "progressToken", received: "object" });
// Belt and braces: the secret must not appear anywhere in the record.
expect(JSON.stringify(warn.mock.calls)).not.toContain("sk-live");
});
it.each([
["a string", "tok-1"],
["an integer", 7],
// The boundary itself is still valid.
["the largest safe integer", Number.MAX_SAFE_INTEGER],
])("keeps a valid progressToken that is %s", async (_label, token) => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
// See the note above: with progress on, the SDK overwrites this.
progress: false,
defaultMetadata: { progressToken: token },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const listToolsReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(metaOf(listToolsReq!).progressToken).toBe(token);
messageLogState.destroy();
});
it("call-time metadata overrides defaultMetadata on key collision", async () => {
client = new InspectorClient(
{
@ -4241,7 +4493,7 @@ describe("InspectorClient", () => {
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}> = [];
client!.addEventListener(

View file

@ -1,10 +1,18 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { getServerType } from "@inspector/core/mcp/config.js";
import {
createTransportNode,
afterAll,
afterEach,
beforeAll,
describe,
it,
expect,
vi,
} from "vitest";
import { getServerType } from "@inspector/core/mcp/config.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createProxyFetch,
readProxyEnv,
withProxyDispatcher,
} from "@inspector/core/mcp/node/transport.js";
} from "@inspector/core/mcp/node/proxyFetch.js";
import type {
InspectorServerSettings,
MCPServerConfig,
@ -16,8 +24,45 @@ import {
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import { Client } from "@modelcontextprotocol/client";
import { createServer, request, type Server } from "node:http";
import type { AddressInfo } from "node:net";
describe("Transport", () => {
/**
* Proxy variables are cleared for the WHOLE file, not just the proxy tests.
*
* `createProxyFetch`'s `EnvHttpProxyAgent` is memoized process-wide and captures
* its proxy URLs when it is constructed (only `NO_PROXY` is re-read per
* request). So on a machine with an ambient `HTTP_PROXY`, any earlier test that
* makes a real connection would build that agent against the ambient proxy, and
* a later test setting `HTTP_PROXY` to its own server would be talking to an
* agent that is not listening. Clearing up front means nothing can build the
* singleton before the proxy tests do.
*/
const AMBIENT_PROXY_VARS = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"NO_PROXY",
"no_proxy",
] as const;
const ambientProxyEnv: Record<string, string | undefined> = {};
beforeAll(() => {
for (const name of AMBIENT_PROXY_VARS) {
ambientProxyEnv[name] = process.env[name];
delete process.env[name];
}
});
afterAll(() => {
for (const name of AMBIENT_PROXY_VARS) {
const previous = ambientProxyEnv[name];
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
});
describe("getServerType", () => {
it("should return stdio for stdio config", () => {
const config: MCPServerConfig = {
@ -218,7 +263,7 @@ describe("Transport", () => {
{ key: "", value: "ignored-empty-key" },
],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -267,7 +312,7 @@ describe("Transport", () => {
const baseSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -305,7 +350,7 @@ describe("Transport", () => {
const settings: InspectorServerSettings = {
headers: [{ key: "X-Tenant", value: "acme" }],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -350,7 +395,7 @@ describe("Transport", () => {
const settings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -396,50 +441,27 @@ describe("Transport", () => {
expect(readProxyEnv()).toBeUndefined();
});
it("withProxyDispatcher returns the original fetch unchanged when no proxy is configured", () => {
it("createProxyFetch returns undefined when no proxy is configured", () => {
clearProxyEnv();
const base = vi.fn();
expect(withProxyDispatcher(base as unknown as typeof fetch)).toBe(base);
expect(createProxyFetch()).toBeUndefined();
});
it("withProxyDispatcher injects an EnvHttpProxyAgent dispatcher into RequestInit", async () => {
it("createProxyFetch returns a fetch when a proxy is configured", () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
const calls: Array<{
input: Parameters<typeof fetch>[0];
init: RequestInit | undefined;
}> = [];
const base = vi.fn(
async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
},
);
const proxied = withProxyDispatcher(base as unknown as typeof fetch);
expect(proxied).not.toBe(base);
await proxied("https://example.com/mcp", { method: "POST" });
expect(calls).toHaveLength(1);
expect(calls[0].input).toBe("https://example.com/mcp");
expect(calls[0].init?.method).toBe("POST");
const dispatcher = (calls[0].init as { dispatcher?: object }).dispatcher;
expect(dispatcher).toBeDefined();
expect(dispatcher?.constructor.name).toBe("EnvHttpProxyAgent");
// Second call reuses the same dispatcher (lazy singleton).
await proxied("https://example.com/mcp");
const dispatcher2 = (calls[1].init as { dispatcher?: object }).dispatcher;
expect(dispatcher2).toBe(dispatcher);
expect(typeof createProxyFetch()).toBe("function");
});
it("createTransportNode wraps the supplied fetch with the proxy dispatcher for streamable-http", async () => {
it("createTransportNode leaves a supplied fetchFn untouched under a proxy", async () => {
// The proxy now lives at the *bottom* of the stack (environment.fetch), so
// a caller-supplied fetch must be used verbatim — the old behavior wrapped
// it and injected a `dispatcher`, which is what #2067 removed.
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
let seenDispatcher: object | undefined;
let seenInit: RequestInit | undefined;
const fetchFn = vi.fn(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
seenDispatcher = (init as { dispatcher?: object } | undefined)
?.dispatcher;
// Reject after capturing so connect() fails fast without hitting a
// real proxy.
seenInit = init;
throw new Error("stop");
},
);
@ -450,24 +472,209 @@ describe("Transport", () => {
const client = new Client({ name: "t", version: "1" });
await expect(client.connect(result.transport)).rejects.toThrow();
expect(fetchFn).toHaveBeenCalled();
expect(seenDispatcher?.constructor.name).toBe("EnvHttpProxyAgent");
expect(
(seenInit as { dispatcher?: unknown } | undefined)?.dispatcher,
).toBeUndefined();
});
it("throws an actionable error when a proxy is configured but undici cannot be loaded", async () => {
describe("through a real proxy server", () => {
// A forwarding proxy, so these exercise the actual undici code path rather
// than a mock. That matters: #2067's second bug was a *runtime* interface
// mismatch between userland undici's dispatcher and Node's built-in fetch
// ("invalid onRequestStart method"), which every mock-based test missed
// because no mock ever dispatches. Only a request that really leaves the
// process can catch that class.
let proxy: Server;
let proxyUrl: string;
let origin: Server;
let originUrl: string;
const proxied: string[] = [];
const seenRequests: Array<{ method: string; probe: string }> = [];
beforeAll(async () => {
origin = createServer((req, res) => {
seenRequests.push({
method: req.method ?? "",
probe: String(req.headers["x-probe"] ?? ""),
});
if (req.url === "/no-content") {
res.writeHead(204);
res.end();
return;
}
if (req.url === "/redirect") {
res.writeHead(302, { location: `${originUrl}/landed` });
res.end();
return;
}
res.writeHead(200, { "content-type": "text/plain", "x-origin": "1" });
res.end("hello from origin");
});
await new Promise<void>((r) => origin.listen(0, "127.0.0.1", r));
originUrl = `http://127.0.0.1:${(origin.address() as AddressInfo).port}`;
proxy = createServer((req, res) => {
// Absolute-form request URI is what makes this a proxy request.
proxied.push(req.url ?? "");
const target = new URL(req.url ?? "");
const upstream = request(
{
host: target.hostname,
port: target.port,
path: target.pathname + target.search,
method: req.method,
headers: req.headers,
},
(up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
},
);
upstream.on("error", () => {
res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", r));
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((r) => proxy.close(() => r()));
await new Promise<void>((r) => origin.close(() => r()));
});
it("routes requests through the proxy and returns a global Response", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
expect(proxyFetch).toBeDefined();
const before = proxied.length;
const res = await proxyFetch!(`${originUrl}/hello`);
// The request really went through the proxy...
expect(proxied.length).toBe(before + 1);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/hello`);
// ...and came back as a *global* Response, not undici's own class. The
// SDK branches on `input instanceof Response` when formatting OAuth
// errors, so a foreign Response silently degrades those messages.
expect(res).toBeInstanceOf(Response);
expect(res.headers).toBeInstanceOf(Headers);
expect(res.status).toBe(200);
expect(res.headers.get("x-origin")).toBe("1");
expect(await res.text()).toBe("hello from origin");
});
it("honors NO_PROXY", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
process.env.NO_PROXY = "127.0.0.1";
const proxyFetch = createProxyFetch();
const before = proxied.length;
const res = await proxyFetch!(`${originUrl}/direct`);
expect(res.status).toBe(200);
// Went straight to the origin — the proxy never saw it.
expect(proxied.length).toBe(before);
});
it("preserves a null-body status", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(`${originUrl}/no-content`);
expect(res.status).toBe(204);
expect(res.body).toBeNull();
});
it("reports url/redirected/type after following a redirect", async () => {
// A rebuilt Response cannot carry these through its constructor, so they
// are reinstated explicitly. Without that, a proxied response reports
// `url: ""` and `redirected: false` even when undici did follow a
// redirect — a fetch-contract regression only proxy users would see.
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(`${originUrl}/redirect`);
expect(res.status).toBe(200);
expect(res.url).toBe(`${originUrl}/landed`);
expect(res.redirected).toBe(true);
expect(res.type).toBe("basic");
});
it("honors a Request's own redirect mode", async () => {
// The Request carries options the adapter must not drop on the way into
// undici. `redirect: "manual"` is the visible one: lose it and a caller
// asking to see the 302 silently gets the followed 200 instead.
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(
new Request(`${originUrl}/redirect`, { redirect: "manual" }),
);
expect(res.status).toBe(302);
expect(res.redirected).toBe(false);
});
it("accepts a URL instance and forwards the init", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(new URL(`${originUrl}/from-url`), {
method: "POST",
headers: { "x-probe": "yes" },
});
expect(res.status).toBe(200);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-url`);
expect(seenRequests[seenRequests.length - 1]).toMatchObject({
method: "POST",
probe: "yes",
});
});
it("accepts a Request as input", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(
new Request(`${originUrl}/from-request`, { method: "GET" }),
);
expect(res.status).toBe(200);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-request`);
});
});
it("reports a packaging fault when undici cannot be loaded", async () => {
// The message deliberately does NOT tell the user to install undici. It
// is a root dependency and always present; the only way this branch is
// reached in a published build is that the bundler mangled the specifier
// — which is exactly what #2067 was, and the old copy sent every affected
// user chasing an install that could not have helped.
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
vi.doMock("undici", () => {
throw new Error("Cannot find module 'undici'");
});
// Re-import after the mock so the dynamic import("undici") inside
// withProxyDispatcher is intercepted.
const { withProxyDispatcher: wrap } =
await import("@inspector/core/mcp/node/transport.js");
const proxied = wrap(globalThis.fetch);
await expect(proxied("https://example.com")).rejects.toThrow(
/HTTPS_PROXY.*undici.*not.*available/i,
// Re-import after the mock so the dynamic import() inside the module is
// intercepted — and so the module-level agent memo starts out empty.
vi.resetModules();
const { createProxyFetch: create } =
await import("@inspector/core/mcp/node/proxyFetch.js");
const proxied = create();
await expect(proxied!("https://example.com")).rejects.toThrow(
/undici.*could not be loaded/i,
);
vi.doUnmock("undici");
vi.resetModules();
});
});
});

View file

@ -0,0 +1,188 @@
/**
* `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: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:6278/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:6278/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([
["a JSON null body", "null"],
["a JSON array body", "[1,2]"],
])("rejects %s with 400 rather than a 500", async (_label, raw) => {
// Both parse cleanly, so the try/catch above sees nothing — destructuring
// them is what used to throw, outside it, turning a bad shape into a 500.
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(post(raw));
expect(res.status).toBe(400);
expect(publish).not.toHaveBeenCalled();
});
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.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>()
.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);
});
});

View file

@ -12,7 +12,16 @@
* - GET fast-path re-check when a concurrent write removed the plaintext
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -292,6 +301,37 @@ describe("server.ts supplemental coverage", () => {
let target: ServerType;
let targetUrl: string;
// Isolate the WHOLE suite from the developer's (or CI's) proxy environment.
// Setting only uppercase HTTP_PROXY in the one proxy test would not be
// enough: undici's EnvHttpProxyAgent prefers lowercase `http_proxy`, and
// honors NO_PROXY dynamically, so an ambient value could route the request
// elsewhere or bypass the proxy and fail the assertion. Suite-level rather
// than per-test because the agent is memoized process-wide once built.
const PROXY_VARS = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"NO_PROXY",
"no_proxy",
] as const;
const savedProxyEnv: Partial<Record<string, string | undefined>> = {};
beforeAll(() => {
for (const name of PROXY_VARS) {
savedProxyEnv[name] = process.env[name];
delete process.env[name];
}
});
afterAll(() => {
for (const name of PROXY_VARS) {
const previous = savedProxyEnv[name];
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
});
beforeEach(async () => {
h = await start();
// A tiny upstream HTTP server we can point /api/fetch at.
@ -341,6 +381,60 @@ describe("server.ts supplemental coverage", () => {
await stop(h);
});
it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
// /api/fetch is the browser's ONLY way out to the network — the web
// client's `environment.fetch` is `createRemoteFetch()`, which forwards
// OAuth discovery and token requests here. On the bare global `fetch` a
// corporate-proxy user could connect to a server but never authorize
// against it, and Node's native NODE_USE_ENV_PROXY does not cover them
// (unsupported at the 22.19 engine floor).
const { createServer } = await import("node:http");
const { request } = await import("node:http");
const seen: string[] = [];
const proxy = createServer((req, res) => {
seen.push(req.url ?? "");
const u = new URL(req.url ?? "");
const up = request(
{
host: u.hostname,
port: u.port,
path: u.pathname + u.search,
method: req.method,
headers: req.headers,
},
(r) => {
res.writeHead(r.statusCode ?? 502, r.headers);
r.pipe(res);
},
);
up.on("error", () => {
res.writeHead(502);
res.end();
});
req.pipe(up);
});
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", () => r()));
const proxyAddr = proxy.address();
const proxyPort =
typeof proxyAddr === "object" && proxyAddr !== null
? proxyAddr.port
: 0;
process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`;
try {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/plain` }),
});
expect(res.status).toBe(200);
expect(seen).toEqual([`${targetUrl}/plain`]);
} finally {
delete process.env.HTTP_PROXY;
await new Promise<void>((r) => proxy.close(() => r()));
}
});
it("forwards method + headers and returns the response body", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
@ -457,7 +551,7 @@ describe("server.ts supplemental coverage", () => {
const base = {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
};
@ -891,7 +985,7 @@ describe("server.ts supplemental coverage", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "shh",
@ -1039,7 +1133,7 @@ describe("server.ts supplemental coverage", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "new-secret",

View file

@ -425,10 +425,11 @@ describe("/api/servers routes", () => {
// Wire envelope unchanged from #1353: pair-array headers, flat
// oauth* fields. Backend splats these into the flat disk shape:
// object headers, nested oauth, plus the inspector-only fields
// at the top level.
// at the top level. Metadata is the exception — it crosses as a
// JSON object, since a `_meta` value may be any JSON (#1910).
settings: {
headers: [{ key: "Authorization", value: "Bearer xyz" }],
metadata: [{ key: "tenant", value: "acme" }],
metadata: { tenant: "acme", limits: { rps: 10 } },
connectionTimeout: 30000,
requestTimeout: 60000,
oauthClientId: "client-abc",
@ -442,7 +443,7 @@ describe("/api/servers routes", () => {
// Disk shape: flat, no `settings` wrapper, object headers, nested oauth.
expect(stored).not.toHaveProperty("settings");
expect(stored.headers).toEqual({ Authorization: "Bearer xyz" });
expect(stored.metadata).toEqual([{ key: "tenant", value: "acme" }]);
expect(stored.metadata).toEqual({ tenant: "acme", limits: { rps: 10 } });
expect(stored.connectionTimeout).toBe(30000);
expect(stored.requestTimeout).toBe(60000);
expect(stored.oauth).toEqual({
@ -462,7 +463,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationParams: [
@ -488,7 +489,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationParams: { kc_idp_hint: "corp" },
@ -509,7 +510,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationUrl: "https://staging.test/authorize",
@ -534,7 +535,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationUrl: "",
@ -557,7 +558,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthTokenUrl: 42,
@ -583,7 +584,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [{ key: "X-Tenant", value: "acme" }],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 45000,
},
@ -617,7 +618,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: true,
@ -649,7 +650,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: false,
@ -671,7 +672,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: "yes",
@ -696,7 +697,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
maxFetchRequests: 5000,
@ -724,7 +725,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
maxFetchRequests: 0,
@ -756,7 +757,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
maxFetchRequests: 1000,
@ -778,7 +779,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
maxFetchRequests: -5,
@ -884,7 +885,7 @@ describe("/api/servers routes", () => {
// headers should be an array of {key, value}; "oops" is a string.
settings: {
headers: "oops",
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
},
@ -915,7 +916,7 @@ describe("/api/servers routes", () => {
// write lock and apply only the settings patch.
settings: {
headers: [{ key: "X-Tenant", value: "acme" }],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
},
@ -957,7 +958,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientId: "",
@ -983,7 +984,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [{ key: "X-A", value: "1" }],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
// Unknown stowaway — must not survive the validator.
@ -1101,7 +1102,7 @@ describe("/api/servers routes", () => {
url: "https://x.test/mcp",
settings: {
headers: [{ key: "X-Tenant", value: "acme" }],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 0,
oauthClientId: "client-abc",
@ -1207,7 +1208,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
roots: [
@ -1255,7 +1256,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
roots: [{ uri: " " }],
@ -1285,7 +1286,7 @@ describe("/api/servers routes", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
// uri must be a string.
@ -1336,7 +1337,7 @@ describe("/api/servers routes", () => {
{ key: "Authorization", value: "Bearer the-token" },
{ key: "X-Tenant", value: "acme" },
],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
},
@ -1368,7 +1369,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [{ key: "API_KEY", value: "abc-123" }],
@ -1416,7 +1417,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 5000,
requestTimeout: 0,
},
@ -1484,7 +1485,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [],
@ -1516,7 +1517,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [
@ -1587,7 +1588,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: "nope",
@ -1614,7 +1615,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [],
@ -1642,7 +1643,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [],
@ -1675,7 +1676,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
env: [{ key: "API_KEY", value: "abc" }],
@ -1775,7 +1776,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientId: "cid",
@ -1935,7 +1936,7 @@ describe("/api/servers routes", () => {
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientId: "cid",
@ -2327,7 +2328,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientId: "cid",
@ -2361,7 +2362,7 @@ describe("/api/servers routes", () => {
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
oauthClientId: "cid",
@ -2658,7 +2659,7 @@ describe("a config-only PUT must not delete stored secrets (#2083)", () => {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -2726,7 +2727,7 @@ describe("a config-only PUT must not delete stored secrets (#2083)", () => {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -2879,7 +2880,7 @@ describe("plaintext migration against a session-scoped store (#1950)", () => {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 45000,
requestTimeout: 60000,
taskTtl: 60000,
@ -2927,7 +2928,7 @@ describe("plaintext migration against a session-scoped store (#1950)", () => {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,

View file

@ -412,7 +412,7 @@ describe("Remote transport e2e", () => {
{ key: "X-Trace", value: "abc123" },
],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,

View file

@ -0,0 +1,482 @@
/**
* 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,
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;
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("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);
process.env.MCP_APP_ORIGIN_PORT = "0";
expect(resolveAppOriginPort()).toBe(0);
});
it.each(["6278abc", "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("appDocumentEmbedders", () => {
it("admits the sandbox proxy AND the Inspector page", () => {
// `frame-ancestors` is checked against EVERY ancestor, not just the parent:
// the document is framed by the proxy, which is framed by the app page.
// Omitting the app page's origin blocks the frame outright (it renders as a
// chrome-error frame that never reaches the bridge).
expect(
appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [
"http://127.0.0.1:6274",
"http://localhost:6274",
]),
).toEqual([
"http://127.0.0.1:6275",
"http://127.0.0.1:6274",
"http://localhost:6274",
]);
});
it("still returns the allow-list when the sandbox never bound", () => {
expect(appDocumentEmbedders(null, ["http://127.0.0.1:6274"])).toEqual([
"http://127.0.0.1:6274",
]);
});
it.each([[null], [undefined], [""]])(
"returns undefined for %j with no allow-list, 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(
appDocumentEmbedders(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("evicts against a retained-size budget, not just the entry count", async () => {
// The count bound alone permits MAX_DOCUMENTS * 8Mi retained for an hour,
// on documents a server under test chooses the size of. Publish a few
// large ones — well under the 32-entry cap — and the earliest must stop
// being served.
const c = createAppOriginController({ port: 0, host: "127.0.0.1" });
controller = c;
const { url } = await c.start();
// 24Mi each: three fit under no count bound, but the third pushes total
// retained past the 64Mi budget and the first has to go.
const big = "x".repeat(24 * 1024 * 1024);
const first = c.publish({ html: big });
const second = c.publish({ html: big });
expect(first).not.toBeNull();
expect(second).not.toBeNull();
const third = c.publish({ html: big });
expect(third).not.toBeNull();
expect((await fetch(first!.url)).status).toBe(404);
// The newest is always served — its frame is about to fetch it.
expect((await fetch(third!.url)).status).toBe(200);
expect(url).toBe(c.getOrigin());
});
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("stops minting URLs when the listener errors AFTER it bound", async () => {
// `publish` gates on `origin`, not on `server`. An error arriving once the
// listener is live must therefore clear `origin` too — otherwise the
// browser keeps being handed URLs on a dead port and never takes the
// srcdoc fallback the caller is promised.
vi.resetModules();
let emitter!: EventEmitter;
vi.doMock("node:http", async () => {
const actual =
await vi.importActual<typeof import("node:http")>("node:http");
return {
...actual,
createServer: () => {
emitter = new EventEmitter();
return Object.assign(emitter, {
listen: (_p: number, _h: string, cb?: () => void) =>
setImmediate(() => cb?.()),
address: () => ({
address: "127.0.0.1",
family: "IPv4",
port: 6278,
}),
close: (cb?: () => void) => cb?.(),
});
},
};
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const mod = await import("../../../../server/app-origin-controller.js");
const c = mod.createAppOriginController({
port: 6278,
host: "127.0.0.1",
});
await c.start();
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
emitter.emit(
"error",
Object.assign(new Error("boom"), { code: "EPIPE" }),
);
expect(c.getOrigin()).toBeNull();
expect(c.publish({ html: "<p>x</p>" })).toBeNull();
} finally {
errorSpy.mockRestore();
vi.doUnmock("node:http");
vi.resetModules();
}
});
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();
}
});
});

View file

@ -123,6 +123,7 @@ describe("startHonoServer autoOpen", () => {
storageDir: undefined,
allowedOrigins: [baseUrl],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: true,

View file

@ -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,

View file

@ -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,53 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
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(6278);
process.env.MCP_APP_ORIGIN_PORT = "9300";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});
it.each([
["the web server", "CLIENT_PORT", "the web server (CLIENT_PORT)"],
[
"the sandbox",
"MCP_SANDBOX_PORT",
"the MCP Apps sandbox (MCP_SANDBOX_PORT)",
],
])(
"yields a dynamic app-origin port when it collides with %s",
(_label, envVar, expectedReason) => {
// The app-origin listener starts BEFORE the web server binds, so a
// configured collision is not a race it loses — it WINS the port and the
// Inspector then dies with EADDRINUSE. `CLIENT_PORT=6278` was a valid
// config before this feature existed.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
process.env[envVar] = "6500";
process.env.MCP_APP_ORIGIN_PORT = "6500";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(0);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(expectedReason),
);
} finally {
warnSpy.mockRestore();
}
},
);
it("leaves an explicit dynamic app-origin port alone", () => {
// 0 means "OS-assigned" for both, so two zeros are not a collision.
process.env.CLIENT_PORT = "6274";
process.env.MCP_SANDBOX_PORT = "0";
process.env.MCP_APP_ORIGIN_PORT = "0";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(0);
});
it("sets MCP_STORAGE_DIR when present", () => {
process.env.MCP_STORAGE_DIR = "/tmp/storage";
expect(buildWebServerConfigFromEnv().storageDir).toBe("/tmp/storage");