Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
84036d7
feat(web): enforce localhost-only binding; expand loopback origin all…
cliffhall Jul 26, 2026
1b08a2d
docs(web): add 'Hosting on a network' note — specific-IP bind + ALLOW…
cliffhall Jul 26, 2026
5995c62
review: harden bind guard + IPv6 loopback consistency across origin/C…
cliffhall Jul 26, 2026
944aeed
review: round-2 fixes — gate plugin config eval, share host formatter…
cliffhall Jul 26, 2026
d8dbf93
review: round-3 fixes — close IPv6 wildcard bypass, default-port orig…
cliffhall Jul 26, 2026
71b21c8
Merge remote-tracking branch 'origin/v2/main' into v2/enforce-loopbac…
cliffhall Jul 26, 2026
ed1f993
review: round-4 fixes — zone-index guard crash, CSP header hardening,…
cliffhall Jul 26, 2026
e571afe
review: round-5 fixes — wildcard allow-list, IPv6-in-CSP reality, URL…
cliffhall Jul 26, 2026
43a9b47
review: round-6 fixes — empty-ALLOWED_ORIGINS fail-open, 0.0.0.0 is a…
cliffhall Jul 26, 2026
7a638ea
review: round-7 fixes — canonical wildcard origins, sandbox URL, dedu…
cliffhall Jul 26, 2026
b955056
review: round-8 fixes — normalize ALLOWED_ORIGINS, reject unusable CL…
cliffhall Jul 26, 2026
b2ac92b
review: round-9 fixes — reject scheme-less ALLOWED_ORIGINS, CLIENT_PO…
cliffhall Jul 26, 2026
a273d5b
review: round-10 fixes — canonicalize the bind host on all origin-lis…
cliffhall Jul 26, 2026
96561b4
review: round-11 polish — verify --dev allowedHosts, dedupe port vali…
cliffhall Jul 26, 2026
e66c24d
review: round-12 fixes — strictPort parity on the launcher dev path, …
cliffhall Jul 26, 2026
0889680
review: round-13 fixes — doc-maintenance for the new file/folder, nor…
cliffhall Jul 26, 2026
420a3cc
review: round-14 fix — restore banner ⊆ allow-list for mapped-IPv4, u…
cliffhall Jul 26, 2026
6ed9b79
review: round-15 fixes — CLI deep-link canonicalization (last Y1 site…
cliffhall Jul 26, 2026
ecbb91b
review: round-16 doc fixes — stale origin-guard advice, mixed-content…
cliffhall Jul 26, 2026
9b3bf44
review: round-17 doc fix — don't recommend [::1] for MCP Apps review …
cliffhall Jul 26, 2026
f18ee36
review: round-18 fix — move isAllInterfacesHost to core/node, wildcar…
cliffhall Jul 26, 2026
a45e0bd
review: round-19 fix — close the IDNA/fullwidth wildcard bypass in th…
cliffhall Jul 26, 2026
28fa001
review: round-20 fixes — untrack stray tsbuildinfo, self-explaining g…
cliffhall Jul 26, 2026
b0d55e3
review: round-21 — guard the CLI OAuth callback bind (AF1), message +…
cliffhall Jul 27, 2026
79392f5
review: round-22 — OAuth callback loopback-only + bracket fixes (#1795)
cliffhall Jul 27, 2026
bec5919
review: round-23 nits — bound isLoopbackHost octets, accept trailing-…
cliffhall Jul 27, 2026
ea7625f
review: round-24 — OAuth guard error is a usage error, not auth_requi…
cliffhall Jul 27, 2026
4ff578c
review: round-25 — preserve CLI exit codes/envelope through the launc…
cliffhall Jul 27, 2026
c4392c7
review: round-26 nits — stale-build guard, non-1 exit smoke, DEBUG=0,…
cliffhall Jul 27, 2026
b52e376
review: round-27 nits — smoke env-pin + docblock, comments (#1795)
cliffhall Jul 27, 2026
83a2dfe
review: round-28 — correct dead-cover comments, ALLOWED_ORIGINS-repla…
cliffhall Jul 27, 2026
13a825c
review: round-29 — TUI smoke env-pin + root README ALLOWED_ORIGINS re…
cliffhall Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ clients/web/dist
clients/**/build
clients/**/coverage
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
/.playwright-mcp/

Expand Down
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ inspector/
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
│ │ │ # resolve-bind-host.ts (bind-host POLICY: 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
│ │ │ # `vite build` on a browser-externalized Node built-in — #1769)
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
Expand Down Expand Up @@ -50,6 +54,14 @@ inspector/
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
│ │ └── 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
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

# 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 \
DANGEROUSLY_BIND_ALL_INTERFACES=true \
CLIENT_PORT=6274 \
MCP_AUTO_OPEN_ENABLED=false
EXPOSE 6274
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ inspector/
│ ├── json/ # JSON + parameter/argument conversion utilities
│ ├── logging/ # Silent pino logger singleton
│ ├── 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
│ └── storage/ # File I/O helpers for the OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
Expand Down Expand Up @@ -198,7 +199,7 @@ docker build -t 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`

Expand Down
2 changes: 1 addition & 1 deletion clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-secret <secret>` | — | OAuth client secret; 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`. |

**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
Expand Down
59 changes: 59 additions & 0 deletions clients/cli/__tests__/stored-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,65 @@ describe("--print-handoff", () => {
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 () => {
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/sse"],
Expand Down
30 changes: 27 additions & 3 deletions clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/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 { consumeMethodOutcome } from "./handlers/consume-outcome.js";
import { runMethod } from "./handlers/run-method.js";
Expand Down Expand Up @@ -422,6 +426,13 @@ function buildHandoff(
transport: "sse" | "http" | "stdio" | undefined,
): McpResponse {
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 sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
Expand All @@ -440,7 +451,7 @@ function buildHandoff(
if (apiToken) params.set("autoConnect", apiToken);
return {
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}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
Expand Down Expand Up @@ -665,7 +676,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
)
.option(
"--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(
"--use-stored-auth",
Expand Down Expand Up @@ -1028,7 +1039,20 @@ export async function runCli(argv?: string[]): Promise<void> {
relogin,
} = parsed;
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(
serverConfig,
serverSettings,
Expand Down
6 changes: 5 additions & 1 deletion clients/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { fileURLToPath } from "url";
import { runCli, validLogLevels } from "./cli.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 isMain =
Expand Down
2 changes: 2 additions & 0 deletions clients/launcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

**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`)

`mcp-inspector --web` chooses which server list the UI shows and whether it is
Expand Down
48 changes: 45 additions & 3 deletions clients/launcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ function clientEntry(client: "web" | "cli" | "tui"): string {
).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();

program
Expand Down Expand Up @@ -49,15 +63,43 @@ async function run(): Promise<void> {
const { runWeb } = await import(clientEntry("web"));
await runWeb(forwardedArgv);
} else if (mode === "cli") {
const { runCli } = await import(clientEntry("cli"));
await runCli(forwardedArgv);
// Route a CLI failure through the CLI's own error sink so `mcp-inspector
// --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 {
const { runTui } = await import(clientEntry("tui"));
await runTui(forwardedArgv);
}
}

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);
});
2 changes: 1 addition & 1 deletion clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-secret <secret>` | — | OAuth client secret (confidential clients); 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

Expand Down
Loading
Loading