feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1796)
Closes #1795. Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms. Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
33e88eac9f
commit
cea22d86b8
34 changed files with 1610 additions and 86 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -8,6 +8,10 @@ clients/web/dist
|
||||||
clients/**/build
|
clients/**/build
|
||||||
clients/**/coverage
|
clients/**/coverage
|
||||||
test-servers/build
|
test-servers/build
|
||||||
|
# TypeScript incremental build cache (written by `tsc -b`); web routes its own
|
||||||
|
# into node_modules/.tmp via tsBuildInfoFile, but a stray `tsc -b` elsewhere
|
||||||
|
# drops one next to the tsconfig — never source.
|
||||||
|
*.tsbuildinfo
|
||||||
.env
|
.env
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
|
||||||
|
|
|
||||||
12
AGENTS.md
12
AGENTS.md
|
|
@ -17,6 +17,10 @@ inspector/
|
||||||
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
|
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
|
||||||
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
|
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
|
||||||
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
|
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
|
||||||
|
│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an
|
||||||
|
│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES;
|
||||||
|
│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost.
|
||||||
|
│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795),
|
||||||
│ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails
|
│ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails
|
||||||
│ │ │ # `vite build` on a browser-externalized Node built-in — #1769)
|
│ │ │ # `vite build` on a browser-externalized Node built-in — #1769)
|
||||||
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
|
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
|
||||||
|
|
@ -50,6 +54,14 @@ inspector/
|
||||||
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
||||||
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
||||||
│ │ └── state/ # InspectorClient state stores consumed by core/react/
|
│ │ └── state/ # InspectorClient state stores consumed by core/react/
|
||||||
|
│ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion,
|
||||||
|
│ │ # walks to the root package.json), hostUrl.ts (shared host
|
||||||
|
│ │ # normalization + detection — formatHostForUrl brackets IPv6,
|
||||||
|
│ │ # canonicalUrlHost canonicalizes a bind host the way a browser
|
||||||
|
│ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind
|
||||||
|
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
|
||||||
|
│ │ # callback listener; also stripBrackets. Used across
|
||||||
|
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
|
||||||
│ ├── react/ # React hooks over the state stores
|
│ ├── react/ # React hooks over the state stores
|
||||||
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
||||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,11 @@ COPY --from=builder /build/modelcontextprotocol-inspector-*.tgz /tmp/inspector.t
|
||||||
RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz
|
RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz
|
||||||
|
|
||||||
# Serve on all interfaces so the UI is reachable from outside the container, and
|
# Serve on all interfaces so the UI is reachable from outside the container, and
|
||||||
# never try to open a browser from inside it.
|
# never try to open a browser from inside it. Binding 0.0.0.0 is refused by
|
||||||
|
# default (it exposes the process-spawning backend to the network); a container
|
||||||
|
# is the sanctioned exception, so opt in explicitly via DANGEROUSLY_BIND_ALL_INTERFACES.
|
||||||
ENV HOST=0.0.0.0 \
|
ENV HOST=0.0.0.0 \
|
||||||
|
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
||||||
CLIENT_PORT=6274 \
|
CLIENT_PORT=6274 \
|
||||||
MCP_AUTO_OPEN_ENABLED=false
|
MCP_AUTO_OPEN_ENABLED=false
|
||||||
EXPOSE 6274
|
EXPOSE 6274
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ inspector/
|
||||||
│ ├── json/ # JSON + parameter/argument conversion utilities
|
│ ├── json/ # JSON + parameter/argument conversion utilities
|
||||||
│ ├── logging/ # Silent pino logger singleton
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
||||||
|
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
|
||||||
│ ├── react/ # React hooks over the state stores
|
│ ├── react/ # React hooks over the state stores
|
||||||
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
||||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
||||||
|
|
@ -198,7 +199,7 @@ docker build -t mcp-inspector .
|
||||||
docker run --rm -p 6274:6274 mcp-inspector
|
docker run --rm -p 6274:6274 mcp-inspector
|
||||||
```
|
```
|
||||||
|
|
||||||
The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server).
|
The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server).
|
||||||
|
|
||||||
## Contributing — `AGENTS.md` and `CLAUDE.md`
|
## Contributing — `AGENTS.md` and `CLAUDE.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs tha
|
||||||
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
|
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
|
||||||
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
|
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
|
||||||
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
|
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). Must bind a **loopback** host (`localhost` / `127.0.0.0/8` / `[::1]`) — the listener receives the authorization code over plaintext `http`, so a non-loopback host hard-errors (no opt-in; use a port-forward if the browser is elsewhere). |
|
||||||
| — | `MCP_AUTO_OPEN_ENABLED` | Browser auto-open **and** non-TTY interactive-OAuth admit: `true` (allow interactive OAuth without a TTY **and** force-open the browser — same as the web launcher), `false` (never open), unset (open on a TTY unless `VITEST` is set). For CI that must not hang, prefer `--stored-auth-only`. |
|
| — | `MCP_AUTO_OPEN_ENABLED` | Browser auto-open **and** non-TTY interactive-OAuth admit: `true` (allow interactive OAuth without a TTY **and** force-open the browser — same as the web launcher), `false` (never open), unset (open on a TTY unless `VITEST` is set). For CI that must not hang, prefer `--stored-auth-only`. |
|
||||||
|
|
||||||
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
|
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,65 @@ describe("--print-handoff", () => {
|
||||||
expect(out.apiToken).toBe("tok123");
|
expect(out.apiToken).toBe("tok123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("canonicalizes the deep-link host so it matches the web allow-list (mapped IPv4)", async () => {
|
||||||
|
// HOST=::ffff:127.0.0.1 binds/serves 127.0.0.1; the deep link must advertise
|
||||||
|
// that canonical host, not [::ffff:127.0.0.1], or the web autoConnect POST
|
||||||
|
// 403s (the web allow-list emits the loopback trio for this HOST).
|
||||||
|
const result = await runCli(
|
||||||
|
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
MCP_INSPECTOR_API_TOKEN: "tok123",
|
||||||
|
HOST: "::ffff:127.0.0.1",
|
||||||
|
CLIENT_PORT: "16274",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expectCliSuccess(result);
|
||||||
|
const out = JSON.parse(result.stdout) as { deepLink: string };
|
||||||
|
expect(out.deepLink.startsWith("http://127.0.0.1:16274/?")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises localhost in the deep link for a wildcard HOST", async () => {
|
||||||
|
// 0.0.0.0 is allow-listed so it connects, but the deep link is handed to a
|
||||||
|
// human — advertise localhost like the web banner does.
|
||||||
|
const result = await runCli(
|
||||||
|
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
MCP_INSPECTOR_API_TOKEN: "tok123",
|
||||||
|
HOST: "0.0.0.0",
|
||||||
|
DANGEROUSLY_BIND_ALL_INTERFACES: "true",
|
||||||
|
CLIENT_PORT: "16274",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expectCliSuccess(result);
|
||||||
|
const out = JSON.parse(result.stdout) as { deepLink: string };
|
||||||
|
expect(out.deepLink.startsWith("http://localhost:16274/?")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a bad --callback-url as a usage error, not auth_required", async () => {
|
||||||
|
// The guard's message contains "OAuth"; without the explicit exit-code pin
|
||||||
|
// the heuristic would map it to auth_required (exit 3) and tell an automated
|
||||||
|
// caller to re-auth on a config error. Fires before connect, so any server.
|
||||||
|
const result = await runCli([
|
||||||
|
"--server-url",
|
||||||
|
"https://x.example/mcp",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--callback-url",
|
||||||
|
"http://0.0.0.0:6276/oauth/callback",
|
||||||
|
]);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
const envelope = JSON.parse(result.stderr.trim()) as {
|
||||||
|
error: { code: string; message: string };
|
||||||
|
};
|
||||||
|
// "error" (USAGE), not "auth_required" — the point of pinning the exit code.
|
||||||
|
expect(envelope.error.code).toBe("error");
|
||||||
|
expect(envelope.error.message).toContain("must bind a loopback host");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => {
|
it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => {
|
||||||
const result = await runCli(
|
const result = await runCli(
|
||||||
["--print-handoff", "--server-url", "https://x.example/sse"],
|
["--print-handoff", "--server-url", "https://x.example/sse"],
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ import {
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} from "@inspector/core/mcp/node/index.js";
|
||||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||||
|
import {
|
||||||
|
canonicalUrlHost,
|
||||||
|
isAllInterfacesHost,
|
||||||
|
} from "@inspector/core/node/hostUrl.js";
|
||||||
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
|
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
|
||||||
import { consumeMethodOutcome } from "./handlers/consume-outcome.js";
|
import { consumeMethodOutcome } from "./handlers/consume-outcome.js";
|
||||||
import { runMethod } from "./handlers/run-method.js";
|
import { runMethod } from "./handlers/run-method.js";
|
||||||
|
|
@ -422,6 +426,13 @@ function buildHandoff(
|
||||||
transport: "sse" | "http" | "stdio" | undefined,
|
transport: "sse" | "http" | "stdio" | undefined,
|
||||||
): McpResponse {
|
): McpResponse {
|
||||||
const host = process.env.HOST || "127.0.0.1";
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
|
// The deep link is a URL handed to a human, so advertise localhost for a
|
||||||
|
// wildcard bind (like the web banner/sandbox URL) rather than the awkward
|
||||||
|
// http://0.0.0.0 / http://[::] — both are allow-listed, but neither is a nice
|
||||||
|
// URL to click; otherwise use the canonical host so it matches the allow-list.
|
||||||
|
const linkHost = isAllInterfacesHost(host)
|
||||||
|
? "localhost"
|
||||||
|
: canonicalUrlHost(host);
|
||||||
const clientPort = process.env.CLIENT_PORT || "6274";
|
const clientPort = process.env.CLIENT_PORT || "6274";
|
||||||
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
||||||
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
||||||
|
|
@ -440,7 +451,7 @@ function buildHandoff(
|
||||||
if (apiToken) params.set("autoConnect", apiToken);
|
if (apiToken) params.set("autoConnect", apiToken);
|
||||||
return {
|
return {
|
||||||
serverUrl: normalizedUrl,
|
serverUrl: normalizedUrl,
|
||||||
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
|
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
|
||||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
||||||
oauthStatePath: statePath,
|
oauthStatePath: statePath,
|
||||||
apiToken: apiToken ?? null,
|
apiToken: apiToken ?? null,
|
||||||
|
|
@ -665,7 +676,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--callback-url <url>",
|
"--callback-url <url>",
|
||||||
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
`OAuth redirect/callback listener URL; must be loopback (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--use-stored-auth",
|
"--use-stored-auth",
|
||||||
|
|
@ -1028,7 +1039,20 @@ export async function runCli(argv?: string[]): Promise<void> {
|
||||||
relogin,
|
relogin,
|
||||||
} = parsed;
|
} = parsed;
|
||||||
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
|
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
|
||||||
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
|
// A bad --callback-url / MCP_OAUTH_CALLBACK_URL is a *usage* error, but its
|
||||||
|
// messages contain "OAuth", which the exit-code heuristic (error-handler.ts)
|
||||||
|
// would otherwise classify as AUTH_REQUIRED (exit 3) — telling an automated
|
||||||
|
// caller to kick the auth flow instead of fixing the flag. `core/` can't
|
||||||
|
// import CliExitCodeError, so pin the class here.
|
||||||
|
let callbackUrlConfig: RunnerOAuthCallbackConfig;
|
||||||
|
try {
|
||||||
|
callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
|
||||||
|
} catch (err) {
|
||||||
|
throw new CliExitCodeError(
|
||||||
|
EXIT_CODES.USAGE,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
await callMethod(
|
await callMethod(
|
||||||
serverConfig,
|
serverConfig,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import { fileURLToPath } from "url";
|
||||||
import { runCli, validLogLevels } from "./cli.js";
|
import { runCli, validLogLevels } from "./cli.js";
|
||||||
import { handleError } from "./error-handler.js";
|
import { handleError } from "./error-handler.js";
|
||||||
|
|
||||||
export { runCli, validLogLevels };
|
// `handleError` is exported so the launcher (which imports `runCli` as a module
|
||||||
|
// and owns the rejection) can route a `mcp-inspector --cli` failure through the
|
||||||
|
// CLI's own sink — preserving the EXIT_CODES map and the JSON `{"error":…}`
|
||||||
|
// envelope that this bin's own `.catch` provides only when run directly.
|
||||||
|
export { runCli, validLogLevels, handleError };
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const isMain =
|
const isMain =
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g
|
||||||
|
|
||||||
All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars.
|
All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars.
|
||||||
|
|
||||||
|
**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: <message>` and exit `1` (append `MCP_DEBUG=1` for the stack).
|
||||||
|
|
||||||
## Web server-list flags (`--web`)
|
## Web server-list flags (`--web`)
|
||||||
|
|
||||||
`mcp-inspector --web` chooses which server list the UI shows and whether it is
|
`mcp-inspector --web` chooses which server list the UI shows and whether it is
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,20 @@ function clientEntry(client: "web" | "cli" | "tui"): string {
|
||||||
).href;
|
).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to append the error stack on a top-level failure. Gated on `MCP_DEBUG`
|
||||||
|
* or `DEBUG` being *meaningfully* set — `"0"` / `"false"` / empty read as off, so
|
||||||
|
* a stray `DEBUG=0` doesn't turn stacks on (and `DEBUG` stays useful for the
|
||||||
|
* `debug` package's namespace filter, which is any non-empty value).
|
||||||
|
*/
|
||||||
|
function wantsDebugStack(): boolean {
|
||||||
|
const on = (v: string | undefined): boolean => {
|
||||||
|
const s = v?.trim().toLowerCase();
|
||||||
|
return !!s && s !== "0" && s !== "false";
|
||||||
|
};
|
||||||
|
return on(process.env.MCP_DEBUG) || on(process.env.DEBUG);
|
||||||
|
}
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
@ -49,8 +63,27 @@ async function run(): Promise<void> {
|
||||||
const { runWeb } = await import(clientEntry("web"));
|
const { runWeb } = await import(clientEntry("web"));
|
||||||
await runWeb(forwardedArgv);
|
await runWeb(forwardedArgv);
|
||||||
} else if (mode === "cli") {
|
} else if (mode === "cli") {
|
||||||
const { runCli } = await import(clientEntry("cli"));
|
// Route a CLI failure through the CLI's own error sink so `mcp-inspector
|
||||||
await runCli(forwardedArgv);
|
// --cli` (a module import here, so the CLI bin's own `.catch(handleError)`
|
||||||
|
// never fires) still honors the EXIT_CODES map and emits the JSON
|
||||||
|
// `{"error":…}` envelope its README documents — instead of the generic
|
||||||
|
// exit-1 catch-all below.
|
||||||
|
const { runCli, handleError } = await import(clientEntry("cli"));
|
||||||
|
try {
|
||||||
|
// On success, let `run()` resolve and the event loop drain (rather than a
|
||||||
|
// `process.exit(0)`) — the CLI closes its own transports, and an eager exit
|
||||||
|
// risks truncating a large stdout payload on a pipe (async on macOS).
|
||||||
|
await runCli(forwardedArgv);
|
||||||
|
} catch (err) {
|
||||||
|
// A stale `cli/build` (built before handleError was exported) would make
|
||||||
|
// this `undefined`; fall through to the generic sink with the real message
|
||||||
|
// rather than throwing "handleError is not a function" over it.
|
||||||
|
if (typeof handleError !== "function") throw err;
|
||||||
|
// write-then-exit like the direct bin; the envelope is a few hundred bytes,
|
||||||
|
// well inside the pipe buffer, so the truncation risk noted above (which is
|
||||||
|
// about a large stdout payload) doesn't apply to this stderr line.
|
||||||
|
handleError(err); // writes the envelope + process.exit(code); never returns
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const { runTui } = await import(clientEntry("tui"));
|
const { runTui } = await import(clientEntry("tui"));
|
||||||
await runTui(forwardedArgv);
|
await runTui(forwardedArgv);
|
||||||
|
|
@ -58,6 +91,15 @@ async function run(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((err: unknown) => {
|
run().catch((err: unknown) => {
|
||||||
console.error("Error running MCP Inspector:", err);
|
// Print the message, not the Error stack — a startup config error (a bad flag,
|
||||||
|
// the OAuth callback loopback guard) should read as actionable, not internal.
|
||||||
|
// The stack is still available under DEBUG / MCP_DEBUG for real faults.
|
||||||
|
console.error(
|
||||||
|
"Error running MCP Inspector:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
if (wantsDebugStack() && err instanceof Error) {
|
||||||
|
console.error(err.stack);
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ OAuth redirect URIs must match **exactly** what you register on the authorizatio
|
||||||
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
| `--client-secret <secret>` | — | OAuth client secret (confidential clients); overrides `client.json`. |
|
| `--client-secret <secret>` | — | OAuth client secret (confidential clients); overrides `client.json`. |
|
||||||
| `--client-metadata-url <url>` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. |
|
| `--client-metadata-url <url>` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. |
|
||||||
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). |
|
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). Must bind a **loopback** host (`localhost` / `127.0.0.0/8` / `[::1]`); a non-loopback host hard-errors, since the listener receives the authorization code over plaintext `http`. |
|
||||||
|
|
||||||
#### Authenticating in the TUI
|
#### Authenticating in the TUI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,25 @@ const isMain =
|
||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
runTui(process.argv).catch((err: unknown) => {
|
runTui(process.argv).catch((err: unknown) => {
|
||||||
console.error(err);
|
// Print the message, not the stack — a startup config error (e.g. the
|
||||||
|
// OAuth callback loopback guard) should read as an actionable message, not
|
||||||
|
// an internal fault. Matches run-web.ts's house pattern. The stack is still
|
||||||
|
// available under DEBUG / MCP_DEBUG for a real fault.
|
||||||
|
console.error("Error:", err instanceof Error ? err.message : err);
|
||||||
|
// Append the stack only when DEBUG / MCP_DEBUG is *meaningfully* set — "0" /
|
||||||
|
// "false" / empty read as off, so a stray DEBUG=0 doesn't force a stack.
|
||||||
|
// (Duplicated from the launcher's `wantsDebugStack` — the two bins can't
|
||||||
|
// import each other; keep them in sync if this logic changes.)
|
||||||
|
const debugOn = (v: string | undefined): boolean => {
|
||||||
|
const s = v?.trim().toLowerCase();
|
||||||
|
return !!s && s !== "0" && s !== "false";
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
(debugOn(process.env.MCP_DEBUG) || debugOn(process.env.DEBUG)) &&
|
||||||
|
err instanceof Error
|
||||||
|
) {
|
||||||
|
console.error(err.stack);
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--callback-url <url>",
|
"--callback-url <url>",
|
||||||
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
`OAuth redirect/callback listener URL; must be loopback (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
||||||
)
|
)
|
||||||
.argument(
|
.argument(
|
||||||
"[target...]",
|
"[target...]",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ The `server/` directory holds the Node-only backend:
|
||||||
- **`vite-hono-plugin.ts`** — mounts the Hono `/api/*` middleware onto the Vite dev server (so `npm run dev` has a live backend).
|
- **`vite-hono-plugin.ts`** — mounts the Hono `/api/*` middleware onto the Vite dev server (so `npm run dev` has a live backend).
|
||||||
- **`server.ts`** — the standalone Hono production server (serves `dist/` + `/api/*`).
|
- **`server.ts`** — the standalone Hono production server (serves `dist/` + `/api/*`).
|
||||||
- **`run-web.ts`** / **`start-vite-dev-server.ts`** — entry points the launcher calls for prod `--web` and `--web --dev`.
|
- **`run-web.ts`** / **`start-vite-dev-server.ts`** — entry points the launcher calls for prod `--web` and `--web --dev`.
|
||||||
- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner.
|
- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner, the default origin allow-list.
|
||||||
|
- **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list).
|
||||||
- **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)).
|
- **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)).
|
||||||
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
||||||
- **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here.
|
- **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here.
|
||||||
|
|
@ -159,6 +160,26 @@ Storybook is first-class here because the components are presentational — each
|
||||||
|
|
||||||
The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale.
|
The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale.
|
||||||
|
|
||||||
|
## Host binding & the origin allow-list
|
||||||
|
|
||||||
|
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
|
||||||
|
|
||||||
|
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
|
||||||
|
|
||||||
|
### Hosting on a network
|
||||||
|
|
||||||
|
The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet:
|
||||||
|
|
||||||
|
- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://<that-host>:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.)
|
||||||
|
- **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`.
|
||||||
|
- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`.
|
||||||
|
|
||||||
|
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
|
||||||
|
|
||||||
|
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||||
|
|
||||||
|
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.
|
||||||
|
|
||||||
## HTTP proxy support
|
## HTTP proxy support
|
||||||
|
|
||||||
The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.
|
The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.
|
||||||
|
|
|
||||||
67
clients/web/server/resolve-bind-host.ts
Normal file
67
clients/web/server/resolve-bind-host.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Resolves and validates the hostname the web server (prod backend + Vite dev
|
||||||
|
* server) binds to. Enforces the localhost-only default so the Inspector can't
|
||||||
|
* accidentally expose its process-spawning proxy to the whole network.
|
||||||
|
*
|
||||||
|
* Shared by `web-server-config.ts` (the Node backend) and `vite.config.ts` (the
|
||||||
|
* dev server) so both bind points enforce the same policy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalUrlHost,
|
||||||
|
isAllInterfacesHost,
|
||||||
|
stripBrackets,
|
||||||
|
} from "../../../core/node/hostUrl.ts";
|
||||||
|
|
||||||
|
/** Env var that opts into binding all network interfaces (see {@link resolveBindHostname}). */
|
||||||
|
export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An explicit, unambiguous opt-in. Unlike a bare `!!value` (which treats the
|
||||||
|
* string `"false"` as truthy), only `"true"`/`"1"` (case-insensitive) enable
|
||||||
|
* the override, so `DANGEROUSLY_BIND_ALL_INTERFACES=false` reads as "off".
|
||||||
|
*/
|
||||||
|
function isEnabled(value: string | undefined): boolean {
|
||||||
|
const v = value?.trim().toLowerCase();
|
||||||
|
return v === "true" || v === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
|
||||||
|
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
|
||||||
|
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
|
||||||
|
* enabled — the published Docker image sets it, since a container must bind
|
||||||
|
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
|
||||||
|
* silently binding wide open. The returned value is trimmed and de-bracketed
|
||||||
|
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
|
||||||
|
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
|
||||||
|
* re-adds the brackets wherever a URL is built.
|
||||||
|
*/
|
||||||
|
export function resolveBindHostname(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
|
const host = (env.HOST ?? "localhost").trim();
|
||||||
|
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
||||||
|
// Show the resolved address when it differs from the typed spelling — the
|
||||||
|
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
||||||
|
// `HOST="0"` renders like `0`, `HOST=0` / `0x0` / `::0` bind `0.0.0.0`), and
|
||||||
|
// "bind a loopback host" reads as a non-sequitur without that hint.
|
||||||
|
const resolved = canonicalUrlHost(host);
|
||||||
|
// Compare de-bracketed on BOTH sides so neither a plain `HOST="::"` nor a
|
||||||
|
// bracketed `HOST="[::]"` is reported as "(resolves to [::])" — the
|
||||||
|
// bracketing is a URL-authority detail, not a resolution.
|
||||||
|
const shown =
|
||||||
|
stripBrackets(resolved) === stripBrackets(host.toLowerCase())
|
||||||
|
? `HOST="${host}"`
|
||||||
|
: `HOST="${host}" (resolves to ${resolved})`;
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to bind ${shown}: this exposes the MCP Inspector to your ` +
|
||||||
|
`entire network, and its backend can spawn local processes and connect ` +
|
||||||
|
`to MCP servers on your behalf — the exposure DNS-rebinding attacks ` +
|
||||||
|
`target. Bind a loopback host (localhost / 127.0.0.1) instead. To ` +
|
||||||
|
`override — only inside an isolated container or trusted network — set ` +
|
||||||
|
`${BIND_ALL_INTERFACES_ENV}=true.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stripBrackets(host);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,10 @@ import {
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
type ServerConfigOptions,
|
type ServerConfigOptions,
|
||||||
} from "../../../core/mcp/node/config.ts";
|
} from "../../../core/mcp/node/config.ts";
|
||||||
import { buildWebServerConfig } from "./web-server-config.js";
|
import {
|
||||||
|
buildWebServerConfig,
|
||||||
|
type WebServerConfig,
|
||||||
|
} from "./web-server-config.js";
|
||||||
import { startViteDevServer } from "./start-vite-dev-server.js";
|
import { startViteDevServer } from "./start-vite-dev-server.js";
|
||||||
import { startHonoServer } from "./server.js";
|
import { startHonoServer } from "./server.js";
|
||||||
import { ensureWebBuild } from "./ensure-web-build.js";
|
import { ensureWebBuild } from "./ensure-web-build.js";
|
||||||
|
|
@ -232,12 +235,25 @@ export async function runWeb(argv: string[]): Promise<number> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const webConfig = buildWebServerConfig({
|
let webConfig: WebServerConfig;
|
||||||
initialMcpConfig,
|
try {
|
||||||
mcpConfigPath,
|
webConfig = buildWebServerConfig({
|
||||||
writable,
|
initialMcpConfig,
|
||||||
initialServers,
|
mcpConfigPath,
|
||||||
});
|
writable,
|
||||||
|
initialServers,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// e.g. the bind-host guard refusing HOST=0.0.0.0 — surface the actionable
|
||||||
|
// message rather than a raw stack trace from the launcher's top-level handler.
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: /* v8 ignore next -- buildWebServerConfig only ever throws Error instances; this fallback is a defensive guard for the impossible non-Error throw. */
|
||||||
|
"Invalid web server configuration.";
|
||||||
|
console.error("Error:", message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const webRoot = join(__dirname, "..");
|
const webRoot = join(__dirname, "..");
|
||||||
const distRoot = join(webRoot, "dist");
|
const distRoot = join(webRoot, "dist");
|
||||||
if (!isDev) {
|
if (!isDev) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import { createServer, type Server } from "node:http";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
canonicalUrlHost,
|
||||||
|
isAllInterfacesHost,
|
||||||
|
} from "../../../core/node/hostUrl.ts";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
|
@ -15,6 +19,59 @@ export interface SandboxControllerOptions {
|
||||||
port: number;
|
port: number;
|
||||||
/** Host to bind (default localhost). */
|
/** Host to bind (default localhost). */
|
||||||
host?: string;
|
host?: string;
|
||||||
|
/**
|
||||||
|
* The backend's origin allow-list (the embedder origins). Used to build the
|
||||||
|
* proxy's `frame-ancestors` so the sandbox iframe isn't CSP-blocked when the
|
||||||
|
* inspector is served on a non-loopback host (network hosting / Docker). The
|
||||||
|
* real callers always pass a non-empty list (`buildWebServerConfig` never
|
||||||
|
* produces an empty `allowedOrigins` since #1795's H1 fix); an empty/omitted
|
||||||
|
* list (only a hand-constructed caller or a test) falls back to loopback-only.
|
||||||
|
*/
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loopback fallback. NB: no `http://[::1]:*` — a **bracketed IPv6 literal is not
|
||||||
|
// a valid CSP `host-source`** (the CSP3 `host-char` grammar is ALPHA/DIGIT/"-"
|
||||||
|
// only). Chromium rejects `http://[::1]:*` and, if it's the sole source, the
|
||||||
|
// directive degrades to `frame-ancestors 'none'` and blocks the frame — verified
|
||||||
|
// empirically. So MCP Apps requires browsing the app at a name or IPv4
|
||||||
|
// (`localhost` / `127.0.0.1`), which these two cover; a bare `[::1]` embedder
|
||||||
|
// can't be admitted by any frame-ancestors source and is unsupported for Apps.
|
||||||
|
const LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A well-formed CSP host-source: `scheme://host[:port]` with no whitespace, CSP
|
||||||
|
* metacharacters, or brackets. `allowedOrigins` comes from
|
||||||
|
* `web-server-config.ts`, where every entry is already a `new URL().origin`
|
||||||
|
* (canonical, no path/newline/`;`), so its **load-bearing job today is dropping
|
||||||
|
* bracketed IPv6** — a `http://[::1]:PORT` literal isn't a valid CSP host-source
|
||||||
|
* and, as the only source, would collapse the directive to `'none'`. The
|
||||||
|
* whitespace/metacharacter exclusions are belt-and-braces: `sandboxFrameAncestors`
|
||||||
|
* takes the list from a caller, not from env directly, so a future caller that
|
||||||
|
* doesn't pre-canonicalize can't corrupt or widen the header — `*` is excluded
|
||||||
|
* too, so a `http://*.example.com` entry (which the origin allow-list already
|
||||||
|
* rejects, but a future caller might not) can't broaden the embedder set. This
|
||||||
|
* filters the caller-supplied `allowedOrigins` only; {@link LOOPBACK_FRAME_ANCESTORS}
|
||||||
|
* is a trusted constant that deliberately uses the `:*` port-wildcard and is
|
||||||
|
* emitted as-is (it does NOT pass this regex, by design).
|
||||||
|
*/
|
||||||
|
const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The proxy's `frame-ancestors` sources. The embedder is the inspector app
|
||||||
|
* page, whose origin is (by construction) in the backend's `allowedOrigins`, so
|
||||||
|
* derive the directive from that list — this keeps the MCP Apps iframe working
|
||||||
|
* on whatever host the app is actually served from, not just loopback.
|
||||||
|
* Malformed entries are dropped (see {@link CSP_HOST_SOURCE}); if nothing valid
|
||||||
|
* remains (the real backend always passes a non-empty list, so this is a
|
||||||
|
* hand-constructed caller, a test, or an all-malformed `ALLOWED_ORIGINS`), it
|
||||||
|
* falls back to the loopback family so the sandbox still loads locally and the
|
||||||
|
* header can't be corrupted.
|
||||||
|
*/
|
||||||
|
export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
|
||||||
|
const valid = (allowedOrigins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||||
|
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
||||||
|
return `frame-ancestors ${sources.join(" ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SandboxController {
|
export interface SandboxController {
|
||||||
|
|
@ -23,27 +80,40 @@ export interface SandboxController {
|
||||||
getUrl(): string | null;
|
getUrl(): string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
|
||||||
|
* integer in `0`–`65535` (0 = OS-assigned). The `^\d+$` test rejects `parseInt`
|
||||||
|
* partial parses (`6274abc`) and the upper bound keeps an out-of-range value
|
||||||
|
* (`70000`) from reaching `server.listen`, which throws `ERR_SOCKET_BAD_PORT`
|
||||||
|
* synchronously — matching the strict `CLIENT_PORT` validation.
|
||||||
|
*/
|
||||||
|
function parseListenPort(raw: string | undefined): number | undefined {
|
||||||
|
const v = raw?.trim();
|
||||||
|
if (!v || !/^\d+$/.test(v)) return undefined;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return n <= 65535 ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
|
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
|
||||||
|
* An invalid value falls through rather than crashing the boot; a set-but-invalid
|
||||||
|
* MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't
|
||||||
|
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
|
||||||
*/
|
*/
|
||||||
export function resolveSandboxPort(): number {
|
export function resolveSandboxPort(): number {
|
||||||
const fromSandbox = process.env.MCP_SANDBOX_PORT;
|
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
|
||||||
if (fromSandbox !== undefined && fromSandbox !== "") {
|
if (fromSandbox === undefined && process.env.MCP_SANDBOX_PORT?.trim()) {
|
||||||
const n = parseInt(fromSandbox, 10);
|
console.warn(
|
||||||
if (!Number.isNaN(n) && n >= 0) return n;
|
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const fromServer = process.env.SERVER_PORT;
|
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
|
||||||
if (fromServer !== undefined && fromServer !== "") {
|
|
||||||
const n = parseInt(fromServer, 10);
|
|
||||||
if (!Number.isNaN(n) && n >= 0) return n;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSandboxController(
|
export function createSandboxController(
|
||||||
options: SandboxControllerOptions,
|
options: SandboxControllerOptions,
|
||||||
): SandboxController {
|
): SandboxController {
|
||||||
const { port, host = "localhost" } = options;
|
const { port, host = "localhost", allowedOrigins } = options;
|
||||||
let server: Server | null = null;
|
let server: Server | null = null;
|
||||||
let sandboxUrl: string | null = null;
|
let sandboxUrl: string | null = null;
|
||||||
|
|
||||||
|
|
@ -54,10 +124,10 @@ export function createSandboxController(
|
||||||
// the inner app document and, since multiple CSPs intersect, would override
|
// the inner app document and, since multiple CSPs intersect, would override
|
||||||
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
|
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
|
||||||
// wrapped HTML (see src/utils/sandbox-csp.ts). The opaque-origin sandbox on
|
// wrapped HTML (see src/utils/sandbox-csp.ts). The opaque-origin sandbox on
|
||||||
// the inner frame is the structural boundary; `frame-ancestors` ensures the
|
// the inner frame is the structural boundary; `frame-ancestors` restricts the
|
||||||
// proxy can only be embedded by the local inspector itself.
|
// proxy to being embedded by the inspector app itself — see
|
||||||
const SANDBOX_PROXY_CSP =
|
// `sandboxFrameAncestors` for how the embedder origins are derived.
|
||||||
"frame-ancestors http://127.0.0.1:* http://localhost:*";
|
const SANDBOX_PROXY_CSP = sandboxFrameAncestors(allowedOrigins);
|
||||||
|
|
||||||
let sandboxHtml: string;
|
let sandboxHtml: string;
|
||||||
try {
|
try {
|
||||||
|
|
@ -132,7 +202,19 @@ export function createSandboxController(
|
||||||
string form only occurs for unix-socket/pipe listens, which
|
string form only occurs for unix-socket/pipe listens, which
|
||||||
this controller never performs. */
|
this controller never performs. */
|
||||||
(addr as unknown as number);
|
(addr as unknown as number);
|
||||||
sandboxUrl = `http://${host}:${actualPort}/sandbox`;
|
// Advertise `localhost` for a wildcard bind: the sandbox URL is
|
||||||
|
// served to the browser (via /api/config) and printed in the banner,
|
||||||
|
// and `http://0.0.0.0:PORT` isn't reachable from the client — but a
|
||||||
|
// wildcard bind serves loopback, so `localhost` is. Otherwise use the
|
||||||
|
// same canonical host the origin allow-list emits, so the served URL
|
||||||
|
// is reachable and its origin is allow-listed (matches the app banner).
|
||||||
|
// (`isAllInterfacesHost` canonicalizes internally too, so passing the
|
||||||
|
// pre-canonicalized host is belt-and-braces.)
|
||||||
|
const canonicalHost = canonicalUrlHost(host);
|
||||||
|
const urlHost = isAllInterfacesHost(canonicalHost)
|
||||||
|
? "localhost"
|
||||||
|
: canonicalHost;
|
||||||
|
sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`;
|
||||||
settle({ port: actualPort, url: sandboxUrl });
|
settle({ port: actualPort, url: sandboxUrl });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||||
|
import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
||||||
import { createSandboxController } from "./sandbox-controller.js";
|
import { createSandboxController } from "./sandbox-controller.js";
|
||||||
import { injectAuthToken } from "./inject-auth-token.js";
|
import { injectAuthToken } from "./inject-auth-token.js";
|
||||||
import type { WebServerConfig } from "./web-server-config.js";
|
import type { WebServerConfig } from "./web-server-config.js";
|
||||||
|
|
@ -39,6 +40,7 @@ export async function startHonoServer(
|
||||||
const sandboxController = createSandboxController({
|
const sandboxController = createSandboxController({
|
||||||
port: config.sandboxPort,
|
port: config.sandboxPort,
|
||||||
host: config.sandboxHost,
|
host: config.sandboxHost,
|
||||||
|
allowedOrigins: config.allowedOrigins,
|
||||||
});
|
});
|
||||||
await sandboxController.start();
|
await sandboxController.start();
|
||||||
|
|
||||||
|
|
@ -131,7 +133,7 @@ export async function startHonoServer(
|
||||||
httpServer.on("error", (err: Error) => {
|
httpServer.on("error", (err: Error) => {
|
||||||
if (err.message.includes("EADDRINUSE")) {
|
if (err.message.includes("EADDRINUSE")) {
|
||||||
console.error(
|
console.error(
|
||||||
`MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port}`,
|
`MCP Inspector PORT IS IN USE at http://${formatHostForUrl(config.hostname)}:${config.port}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ export async function startViteDevServer(
|
||||||
server: {
|
server: {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
host: config.hostname,
|
host: config.hostname,
|
||||||
|
// `strictPort: true` (matching `vite.config.ts`) so a busy port fails
|
||||||
|
// loudly instead of silently binding a different one — the origin
|
||||||
|
// allow-list and the sandbox `frame-ancestors` are derived from
|
||||||
|
// `config.port`, so a drifted bind would 403 every connect and CSP-block
|
||||||
|
// the MCP Apps iframe while the banner advertises the unusable port.
|
||||||
|
strictPort: true,
|
||||||
// Allow Vite to serve source files from the repo root (core/ lives
|
// Allow Vite to serve source files from the repo root (core/ lives
|
||||||
// outside clients/web), matching `vite.config.ts`'s `server.fs.allow`.
|
// outside clients/web), matching `vite.config.ts`'s `server.fs.allow`.
|
||||||
fs: {
|
fs: {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
||||||
const sandboxController = createSandboxController({
|
const sandboxController = createSandboxController({
|
||||||
port: config.sandboxPort,
|
port: config.sandboxPort,
|
||||||
host: config.sandboxHost,
|
host: config.sandboxHost,
|
||||||
|
allowedOrigins: config.allowedOrigins,
|
||||||
});
|
});
|
||||||
await sandboxController.start();
|
await sandboxController.start();
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue