Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ RUN mkdir -p /home/node/.mcp-inspector && chown -R node:node /home/node/.mcp-ins
USER node
WORKDIR /home/node

# Report readiness by probing the served SPA (`/` needs no auth). The probe
# connects to the address derived from the same `HOST` the server binds (a
# wildcard maps to loopback), so overriding `HOST` to one interface keeps the
# healthcheck valid (#2424) — see the script's header. Only `--web` has a
# Report readiness by probing the unauthenticated `/healthz` route (#2438).
# The probe connects to the address derived from the same `HOST` the server
# binds (a wildcard maps to loopback), so overriding `HOST` to one interface
# keeps the healthcheck valid (#2424), and uses https when both
# `INSPECTOR_TLS_*` paths are set, without verifying the certificate (#2825)
# — see the script's header. Only `--web` has a
# server to probe, so the script reads the launch mode from PID 1's argv and
# reports a `--cli`/`--tui` container healthy while it runs (#2415).
COPY --from=builder /build/scripts/docker-healthcheck.mjs /usr/local/lib/mcp-inspector-healthcheck.mjs
Expand Down
2 changes: 1 addition & 1 deletion docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,5 @@ docker run --rm -u 0 --entrypoint chown \
-R node:node /data
```

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. Because it binds a non-loopback address, the image's startup banner always carries the same exposure warning any non-loopback bind prints ([Hosting on a network](../clients/web/README.md#hosting-on-a-network)) — expected here, and a reminder that the published port, not the container's bind, decides who can load the page. If you **remap the published port** (`-p 127.0.0.1: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 127.0.0.1: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 at the address `HOST` binds (a wildcard such as `0.0.0.0` is probed on loopback), so pinning `-e HOST=` to one interface keeps it valid. `--cli` and `--tui` have no web server, so the probe detects those modes from the container's arguments and reports the container healthy while it runs, with no `--no-healthcheck` needed. An external orchestrator — a Kubernetes liveness/readiness probe, a Compose `healthcheck` — can probe `GET /healthz` on the web port: it needs no token and returns only `{"status":"ok"}` (see [Health check](../clients/web/README.md#health-check)).
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. Because it binds a non-loopback address, the image's startup banner always carries the same exposure warning any non-loopback bind prints ([Hosting on a network](../clients/web/README.md#hosting-on-a-network)) — expected here, and a reminder that the published port, not the container's bind, decides who can load the page. If you **remap the published port** (`-p 127.0.0.1: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 127.0.0.1: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 backend's `/healthz` at the address `HOST` binds (a wildcard such as `0.0.0.0` is probed on loopback), so pinning `-e HOST=` to one interface keeps it valid. When the web UI is served over HTTPS (both `INSPECTOR_TLS_KEY_PATH` and `INSPECTOR_TLS_CERT_PATH` set — see [Serving the web UI over HTTPS](./tls.md)) the probe uses `https` and skips certificate verification, since it only ever reaches the container's own listener and a self-signed or private-CA certificate is the normal case there. `--cli` and `--tui` have no web server, so the probe detects those modes from the container's arguments and reports the container healthy while it runs, with no `--no-healthcheck` needed. An external orchestrator — a Kubernetes liveness/readiness probe, a Compose `healthcheck` — can probe `GET /healthz` on the web port: it needs no token and returns only `{"status":"ok"}` (see [Health check](../clients/web/README.md#health-check)).

65 changes: 56 additions & 9 deletions scripts/docker-healthcheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,26 @@
* `core/node/hostUrl.ts`; the few lines of host handling below lean on
* `new URL()` for canonicalization, the same primitive `canonicalUrlHost`
* uses, so legacy spellings (`HOST=0`, `0x0`, `::0`) resolve to the wildcard
* the socket actually binds. `/` needs no auth, and Node's global `fetch` is
* used because the slim image has no curl or wget.
* the socket actually binds. Node's own `http`/`https` clients are used
* because the slim image has no curl or wget.
*
* It probes `GET /healthz` (#2438) rather than `/`: the health route needs no
* token, is exempt from the Host allow-list (so a specific `HOST` the probe
* addresses by name still answers), and returns a fixed body, where `/` reads
* and token-injects `index.html` on every probe.
*
* The scheme follows the server's own TLS rule (#2825): with both
* `INSPECTOR_TLS_KEY_PATH` and `INSPECTOR_TLS_CERT_PATH` set (a blank value is
* unset, as `clients/web/server/tls.ts` reads them) the listener speaks only
* https, so an `http:` probe failed every time and a correctly running TLS
* container was reported unhealthy forever. The https probe skips certificate
* verification: it connects to the container's own listener, a self-signed or
* private-CA certificate is the normal case there, and the certificate names
* the public hostname rather than the loopback address probed — so the check
* would fail on exactly the deployments it is for while proving nothing about
* liveness. That is also why the probe is not `fetch`: Node's global `fetch`
* offers no per-request way to skip verification without importing `undici`,
* which the script, copied outside the installed package, cannot resolve.
*
* Only `--web` has a server to probe (#2415). `--cli` and `--tui` run the same
* image with different args, and a HEALTHCHECK baked into the image cannot be
Expand All @@ -39,12 +57,28 @@
*/

import { readFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { basename } from "node:path";
import { pathToFileURL } from "node:url";

const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = "6274";

/** The web backend's unauthenticated health route (`HEALTH_PATH`, #2438). */
const HEALTH_PATH = "/healthz";

/**
* True when the web listener serves https: both TLS variables set, a blank
* value counting as unset — the rule `resolveTlsConfig` applies. A half-set
* pair is a startup error there, so it never reaches a running listener.
*/
export function tlsEnabled(env) {
return Boolean(
env.INSPECTOR_TLS_KEY_PATH?.trim() && env.INSPECTOR_TLS_CERT_PATH?.trim(),
);
}

/**
* Wildcard bind hosts, as `new URL().hostname` spells them, mapped to the
* loopback address a probe connects to. `[::ffff:0:0]` is the IPv4-mapped
Expand All @@ -57,8 +91,8 @@
]);

/**
* The URL the healthcheck fetches, derived from `HOST` and `CLIENT_PORT` in
* `env`. Throws when `HOST` is not a valid URL host, which the caller reports
* The URL the healthcheck fetches, derived from `HOST`, `CLIENT_PORT` and the
* TLS variables in `env`. Throws when `HOST` is not a valid URL host, which the caller reports
* as unhealthy — a server could not have bound it either.
*/
export function probeUrl(env) {
Expand All @@ -70,10 +104,11 @@
.trim()
.replace(/^\[(.*)\]$/, "$1")
.split("%")[0];
if (bare === "") return `http://${DEFAULT_HOST}:${port}/`;
const scheme = tlsEnabled(env) ? "https" : "http";
if (bare === "") return `${scheme}://${DEFAULT_HOST}:${port}${HEALTH_PATH}`;
const authority = bare.includes(":") ? `[${bare}]` : bare;
const host = new URL(`http://${authority}`).hostname;
return `http://${WILDCARD_TO_LOOPBACK.get(host) ?? host}:${port}/`;
return `${scheme}://${WILDCARD_TO_LOOPBACK.get(host) ?? host}:${port}${HEALTH_PATH}`;
}

/** The image's ENTRYPOINT, as the launcher's argv names it. */
Expand Down Expand Up @@ -118,14 +153,26 @@
}
}

/** True when the web UI answers `/` with a 2xx; false on any failure. */
/**
* True when the web backend answers the health route with a 2xx; false on any
* failure. An https probe does not verify the certificate — see the header.
*/
export async function probe(env) {
let url;
try {
const res = await fetch(probeUrl(env));
return res.ok;
url = new URL(probeUrl(env));
} catch {
return false;
}
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve) => {
const req = send(url, { rejectUnauthorized: false }, (res) => {
Comment thread
cliffhall marked this conversation as resolved.
Dismissed
res.resume();
resolve(res.statusCode >= 200 && res.statusCode < 300);
});
req.on("error", () => resolve(false));
req.end();
});
}

/**
Expand Down
127 changes: 119 additions & 8 deletions scripts/docker-healthcheck.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
* its own family. The `probe` cases run it against a real listener, since the
* exit status is the whole contract Docker reads.
*
* #2825 made the scheme follow the server's TLS rule — https when both
* `INSPECTOR_TLS_*` paths are set — and moved the probe to `/healthz`; the
* https `probe` case runs against a real TLS listener with a throwaway
* self-signed certificate, which the probe must accept.
*
* `launchMode` is #2415: only `--web` has a server, so the verdict reads the
* mode from PID 1's argv and a `--cli`/`--tui` container is not probed.
*
Expand All @@ -15,8 +20,10 @@
*/

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
Expand All @@ -26,8 +33,14 @@ import {
probe,
probeUrl,
readPid1Argv,
tlsEnabled,
} from "./docker-healthcheck.mjs";

const TLS = {
INSPECTOR_TLS_KEY_PATH: "/tls/key.pem",
INSPECTOR_TLS_CERT_PATH: "/tls/cert.pem",
};

describe("probeUrl", () => {
const cases = [
// [HOST, expected host in the URL]
Expand All @@ -52,35 +65,77 @@ describe("probeUrl", () => {
for (const [host, expected] of cases) {
it(`HOST=${JSON.stringify(host)} probes ${expected}`, () => {
const env = host === undefined ? {} : { HOST: host };
assert.equal(probeUrl(env), `http://${expected}:6274/`);
assert.equal(probeUrl(env), `http://${expected}:6274/healthz`);
});
}

it("takes the port from CLIENT_PORT", () => {
assert.equal(
probeUrl({ HOST: "172.17.0.2", CLIENT_PORT: "8080" }),
"http://172.17.0.2:8080/",
"http://172.17.0.2:8080/healthz",
);
});

it("trims CLIENT_PORT and treats an empty one as unset, as the server does", () => {
assert.equal(probeUrl({ CLIENT_PORT: " 8080 " }), "http://127.0.0.1:8080/");
assert.equal(probeUrl({ CLIENT_PORT: " " }), "http://127.0.0.1:6274/");
assert.equal(probeUrl({ CLIENT_PORT: "" }), "http://127.0.0.1:6274/");
assert.equal(
probeUrl({ CLIENT_PORT: " 8080 " }),
"http://127.0.0.1:8080/healthz",
);
assert.equal(
probeUrl({ CLIENT_PORT: " " }),
"http://127.0.0.1:6274/healthz",
);
assert.equal(
probeUrl({ CLIENT_PORT: "" }),
"http://127.0.0.1:6274/healthz",
);
});

it("probes https when both TLS paths are set", () => {
assert.equal(probeUrl(TLS), "https://127.0.0.1:6274/healthz");
assert.equal(
probeUrl({ ...TLS, HOST: "::", CLIENT_PORT: "8443" }),
"https://[::1]:8443/healthz",
);
assert.equal(
probeUrl({ ...TLS, HOST: "" }),
"https://127.0.0.1:6274/healthz",
);
});

it("throws on a HOST no server could bind", () => {
assert.throws(() => probeUrl({ HOST: "not a host" }));
});
});

describe("tlsEnabled", () => {
const cases = [
// [env, expected] — the rule `resolveTlsConfig` applies.
[{}, false],
[TLS, true],
[{ ...TLS, INSPECTOR_TLS_KEY_PATH: "" }, false],
[{ ...TLS, INSPECTOR_TLS_CERT_PATH: "" }, false],
[{ ...TLS, INSPECTOR_TLS_KEY_PATH: " " }, false],
[{ INSPECTOR_TLS_KEY_PATH: "/tls/key.pem" }, false],
[{ INSPECTOR_TLS_CERT_PATH: "/tls/cert.pem" }, false],
];
for (const [env, expected] of cases) {
it(`${JSON.stringify(env)} is ${expected}`, () => {
assert.equal(tlsEnabled(env), expected);
});
}
});

describe("probe", () => {
let server;
let port;
let status = 200;

let path;

before(async () => {
server = createServer((_req, res) => {
server = createServer((req, res) => {
path = req.url;
res.statusCode = status;
res.end();
});
Expand All @@ -89,12 +144,13 @@ describe("probe", () => {
});
after(() => new Promise((resolve) => server.close(resolve)));

it("is healthy when / answers 2xx", async () => {
it("is healthy when /healthz answers 2xx", async () => {
status = 200;
assert.equal(await probe({ HOST: "127.0.0.1", CLIENT_PORT: port }), true);
assert.equal(path, "/healthz");
});

it("is unhealthy when / answers non-2xx", async () => {
it("is unhealthy when /healthz answers non-2xx", async () => {
status = 503;
assert.equal(await probe({ HOST: "127.0.0.1", CLIENT_PORT: port }), false);
});
Expand All @@ -115,6 +171,61 @@ describe("probe", () => {
});
});

describe("probe over https", () => {
let dir;
let server;
let port;

before(async () => {
// Generated rather than committed, as in clients/web's selfSignedTls
// fixture: a checked-in key trips secret scanners and a checked-in
// certificate expires on a date nobody chose.
dir = mkdtempSync(join(tmpdir(), "healthcheck-tls-"));
const keyPath = join(dir, "key.pem");
const certPath = join(dir, "cert.pem");
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
keyPath,
"-out",
certPath,
"-days",
"1",
"-subj",
"/CN=localhost",
],
{ stdio: "ignore" },
);
server = createHttpsServer(
{ key: readFileSync(keyPath), cert: readFileSync(certPath) },
(req, res) => {
res.statusCode = req.url === "/healthz" ? 200 : 404;
res.end();
},
);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
port = String(server.address().port);
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
rmSync(dir, { recursive: true, force: true });
});

it("is healthy against a self-signed TLS listener", async () => {
assert.equal(await probe({ ...TLS, CLIENT_PORT: port }), true);
});

it("is unhealthy when the TLS variables are unset (http to an https port)", async () => {
assert.equal(await probe({ CLIENT_PORT: port }), false);
});
});

describe("launchMode", () => {
const BIN = "/usr/local/bin/mcp-inspector";
const cases = [
Expand Down
Loading