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:
commit
baf4fe954f
12 changed files with 9048 additions and 1271 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ describe("Remote transport e2e", () => {
|
|||
{ key: "X-Trace", value: "abc123" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue