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)
780 lines
26 KiB
TypeScript
780 lines
26 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
import { createServer, type Server } from "node:http";
|
|
import { join, dirname } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
|
|
import {
|
|
normalizeServerUrl,
|
|
deepLinkTransport,
|
|
refreshStoredAuthToken,
|
|
} from "../src/cli.js";
|
|
import {
|
|
createTestServerHttp,
|
|
createEchoTool,
|
|
createTestServerInfo,
|
|
} from "@modelcontextprotocol/inspector-test-server";
|
|
|
|
/**
|
|
* Writes an oauth.json fixture in the plain `{ servers, idpSessions }` layout
|
|
* the web backend persists (also accepted: the legacy `{ state, version }`
|
|
* envelope). The CLI reads it through the shared `parseOAuthPersistBlob`, so
|
|
* both sides agree on the format and the URL-normalised server key.
|
|
*/
|
|
function writeOAuthFixture(servers: Record<string, unknown>): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "inspector-cli-stored-auth-"));
|
|
const file = join(dir, "oauth.json");
|
|
writeFileSync(file, JSON.stringify({ servers, idpSessions: {} }), "utf8");
|
|
return file;
|
|
}
|
|
|
|
describe("normalizeServerUrl", () => {
|
|
it("canonicalises a URL via new URL().href (lowercases scheme/host)", () => {
|
|
expect(normalizeServerUrl("HTTP://Example.COM/Mcp")).toBe(
|
|
"http://example.com/Mcp",
|
|
);
|
|
});
|
|
|
|
it("returns the raw string when the value is not a parseable URL", () => {
|
|
expect(normalizeServerUrl("not a url")).toBe("not a url");
|
|
});
|
|
});
|
|
|
|
describe("deepLinkTransport", () => {
|
|
it("honors an explicit sse/http transport over the URL path", () => {
|
|
expect(deepLinkTransport("https://x.example/mcp", "sse")).toBe("sse");
|
|
expect(deepLinkTransport("https://x.example/sse", "http")).toBe("http");
|
|
});
|
|
|
|
it("auto-detects sse from a /sse path when no transport is given", () => {
|
|
expect(deepLinkTransport("https://x.example/sse", undefined)).toBe("sse");
|
|
});
|
|
|
|
it("defaults to http for a /mcp path, an ambiguous path, or stdio", () => {
|
|
expect(deepLinkTransport("https://x.example/mcp", undefined)).toBe("http");
|
|
expect(deepLinkTransport("https://x.example/", undefined)).toBe("http");
|
|
expect(deepLinkTransport("https://x.example/mcp", "stdio")).toBe("http");
|
|
});
|
|
|
|
it("defaults to http for an unparseable URL without throwing", () => {
|
|
expect(deepLinkTransport("not a url", undefined)).toBe("http");
|
|
});
|
|
});
|
|
|
|
describe("refreshStoredAuthToken", () => {
|
|
const SERVER = "https://api.example/mcp";
|
|
const freshTokens = {
|
|
access_token: "refreshed-access-token",
|
|
token_type: "Bearer",
|
|
refresh_token: "rotated-refresh-token",
|
|
expires_in: 3600,
|
|
};
|
|
|
|
it("runs the refresh grant, injects the fresh token, and persists the rotation", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
clientInformation: { client_id: "cid", client_secret: "sec" },
|
|
serverMetadata: {
|
|
issuer: "https://auth.example",
|
|
token_endpoint: "https://auth.example/token",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const refresh = vi.fn().mockResolvedValue(freshTokens);
|
|
const discover = vi.fn();
|
|
const token = await refreshStoredAuthToken(SERVER, path, {
|
|
refresh,
|
|
discover,
|
|
});
|
|
expect(token).toBe("refreshed-access-token");
|
|
// Stored metadata present → no discovery needed.
|
|
expect(discover).not.toHaveBeenCalled();
|
|
// Refresh called with the stored refresh token + client information.
|
|
expect(refresh).toHaveBeenCalledTimes(1);
|
|
const [, opts] = refresh.mock.calls[0]!;
|
|
expect(opts.refreshToken).toBe("old-refresh");
|
|
expect(opts.clientInformation).toEqual({
|
|
client_id: "cid",
|
|
client_secret: "sec",
|
|
});
|
|
// Rotation persisted back under the same key.
|
|
const persisted = JSON.parse(readFileSync(path, "utf8")) as {
|
|
servers: Record<string, { tokens?: { refresh_token?: string } }>;
|
|
};
|
|
expect(persisted.servers[SERVER]?.tokens?.refresh_token).toBe(
|
|
"rotated-refresh-token",
|
|
);
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("discovers the auth-server metadata when it was not persisted", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
clientInformation: { client_id: "cid" },
|
|
},
|
|
});
|
|
try {
|
|
const refresh = vi.fn().mockResolvedValue(freshTokens);
|
|
const discover = vi.fn().mockResolvedValue({
|
|
issuer: "https://api.example",
|
|
token_endpoint: "https://api.example/token",
|
|
});
|
|
const token = await refreshStoredAuthToken(SERVER, path, {
|
|
refresh,
|
|
discover,
|
|
});
|
|
expect(token).toBe("refreshed-access-token");
|
|
expect(discover).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("passes metadata=undefined to the refresh grant when discovery returns nothing", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
clientInformation: { client_id: "cid" },
|
|
},
|
|
});
|
|
try {
|
|
const refresh = vi.fn().mockResolvedValue(freshTokens);
|
|
const discover = vi.fn().mockResolvedValue(undefined);
|
|
const token = await refreshStoredAuthToken(SERVER, path, {
|
|
refresh,
|
|
discover,
|
|
});
|
|
expect(token).toBe("refreshed-access-token");
|
|
const [, opts] = refresh.mock.calls[0]!;
|
|
expect(opts.metadata).toBeUndefined();
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("uses the distinct no_client_information code when client info is missing", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
},
|
|
});
|
|
try {
|
|
await expect(
|
|
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
|
|
).rejects.toMatchObject({
|
|
exitCode: 3,
|
|
envelope: { code: "no_client_information" },
|
|
});
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("throws AUTH_REQUIRED when no refresh token is stored", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { access_token: "only-access", token_type: "Bearer" },
|
|
},
|
|
});
|
|
try {
|
|
await expect(
|
|
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
|
|
).rejects.toMatchObject({ exitCode: 3 });
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("throws AUTH_REQUIRED when a refresh token is present but client information is missing", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
},
|
|
});
|
|
try {
|
|
await expect(
|
|
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
|
|
).rejects.toMatchObject({ exitCode: 3 });
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
|
|
it("surfaces a refresh-grant failure as AUTH_REQUIRED (refresh_failed)", async () => {
|
|
const path = writeOAuthFixture({
|
|
[SERVER]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
clientInformation: { client_id: "cid" },
|
|
serverMetadata: {
|
|
issuer: "https://auth.example",
|
|
token_endpoint: "https://auth.example/token",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const refresh = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("invalid_grant: token revoked"));
|
|
await expect(
|
|
refreshStoredAuthToken(SERVER, path, { refresh }),
|
|
).rejects.toMatchObject({
|
|
exitCode: 3,
|
|
envelope: { code: "refresh_failed" },
|
|
});
|
|
} finally {
|
|
rmSync(path, { force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("--use-stored-auth", () => {
|
|
let server: ReturnType<typeof createTestServerHttp>;
|
|
let serverUrl: string;
|
|
let fixturePath: string;
|
|
const TOKEN = "stored-access-token-abc";
|
|
|
|
beforeAll(async () => {
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
await server.start();
|
|
serverUrl = server.url;
|
|
fixturePath = writeOAuthFixture({
|
|
[serverUrl]: { tokens: { access_token: TOKEN, token_type: "Bearer" } },
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
rmSync(fixturePath, { force: true });
|
|
});
|
|
|
|
it("injects the stored token as Authorization: Bearer on the outgoing request", async () => {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const recorded = server.getRecordedRequests();
|
|
expect(recorded.length).toBeGreaterThan(0);
|
|
const last = recorded[recorded.length - 1]!;
|
|
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
|
|
});
|
|
|
|
it("merges with --header (explicit headers + stored auth coexist)", async () => {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--header",
|
|
"X-Trace: abc",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const last = server.getRecordedRequests().at(-1)!;
|
|
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
|
|
expect(last.headers?.["x-trace"]).toBe("abc");
|
|
});
|
|
|
|
it("errors clearly when --server-url is missing", async () => {
|
|
const result = await runCli(
|
|
["--use-stored-auth", "--method", "tools/list"],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
|
|
);
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toContain("--use-stored-auth requires --server-url");
|
|
});
|
|
|
|
it("errors clearly when --wait-for-auth is set without --server-url", async () => {
|
|
// Exercises the `--wait-for-auth` arm of the shared missing-server-url
|
|
// guard's message ternary (the --use-stored-auth arm is covered above).
|
|
const result = await runCli(
|
|
["--wait-for-auth", "5", "--method", "tools/list"],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
|
|
);
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toContain("--wait-for-auth requires --server-url");
|
|
});
|
|
|
|
it("errors with exit 3 (AUTH_REQUIRED) and lists stored keys when no token matches", async () => {
|
|
const other = writeOAuthFixture({
|
|
"https://other.example/mcp": {
|
|
tokens: { access_token: "x", token_type: "Bearer" },
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: other } },
|
|
);
|
|
expect(result.exitCode).toBe(3);
|
|
const env = JSON.parse(result.stderr.trim()) as {
|
|
error: { code: string; message: string };
|
|
};
|
|
expect(env.error.code).toBe("no_stored_token");
|
|
expect(env.error.message).toContain("No stored OAuth token");
|
|
expect(env.error.message).toContain("https://other.example/mcp");
|
|
} finally {
|
|
rmSync(other, { force: true });
|
|
}
|
|
});
|
|
|
|
it("matches a stored key even when --server-url differs by URL normalisation", async () => {
|
|
// Store under the normalised form; pass the upper-cased scheme on the
|
|
// command line. new URL().href lowercases the scheme, so the lookup still
|
|
// resolves.
|
|
const normalised = normalizeServerUrl(serverUrl);
|
|
const upper = serverUrl.replace("http://", "HTTP://");
|
|
const fixture = writeOAuthFixture({
|
|
[normalised]: { tokens: { access_token: TOKEN, token_type: "Bearer" } },
|
|
});
|
|
try {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
upper,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
|
|
);
|
|
expectCliSuccess(result);
|
|
} finally {
|
|
rmSync(fixture, { force: true });
|
|
}
|
|
});
|
|
|
|
it("honours MCP_STORAGE_DIR when MCP_INSPECTOR_OAUTH_STATE_PATH is unset", async () => {
|
|
const dir = dirname(fixturePath);
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_STORAGE_DIR: dir } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const last = server.getRecordedRequests().at(-1)!;
|
|
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
|
|
});
|
|
|
|
it("refreshes a stored refresh_token end-to-end and injects the fresh access token (#1665)", async () => {
|
|
// Minimal OAuth token endpoint: honors the refresh_token grant with a
|
|
// rotated token pair, so the CLI's real SDK refresh path has something to
|
|
// talk to (the MCP test server accepts any bearer, so a successful connect
|
|
// proves the refreshed token was injected).
|
|
let tokenRequests = 0;
|
|
const tokenServer: Server = createServer((req, res) => {
|
|
tokenRequests += 1;
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
access_token: "refreshed-access-token",
|
|
token_type: "Bearer",
|
|
refresh_token: "rotated-refresh-token",
|
|
expires_in: 3600,
|
|
}),
|
|
);
|
|
});
|
|
await new Promise<void>((resolve) => tokenServer.listen(0, resolve));
|
|
const addr = tokenServer.address();
|
|
const tokenBase =
|
|
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
|
|
const fixture = writeOAuthFixture({
|
|
[serverUrl]: {
|
|
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
|
clientInformation: { client_id: "cid", client_secret: "sec" },
|
|
serverMetadata: {
|
|
issuer: tokenBase,
|
|
token_endpoint: `${tokenBase}/token`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
|
|
);
|
|
expectCliSuccess(result);
|
|
expect(tokenRequests).toBeGreaterThan(0);
|
|
const last = server.getRecordedRequests().at(-1)!;
|
|
expect(last.headers?.authorization).toBe("Bearer refreshed-access-token");
|
|
// Rotation persisted so a subsequent run reuses the new refresh token.
|
|
const persisted = JSON.parse(readFileSync(fixture, "utf8")) as {
|
|
servers: Record<string, { tokens?: { refresh_token?: string } }>;
|
|
};
|
|
expect(persisted.servers[serverUrl]?.tokens?.refresh_token).toBe(
|
|
"rotated-refresh-token",
|
|
);
|
|
} finally {
|
|
rmSync(fixture, { force: true });
|
|
await new Promise<void>((resolve) => tokenServer.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("falls back to the stored access token when the refresh grant fails (#1665 review #1)", async () => {
|
|
// Token endpoint that rejects the refresh grant, so the CLI must fall back
|
|
// to the still-present stored access token instead of hard-failing.
|
|
const tokenServer: Server = createServer((_req, res) => {
|
|
res.writeHead(400, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
});
|
|
await new Promise<void>((resolve) => tokenServer.listen(0, resolve));
|
|
const addr = tokenServer.address();
|
|
const tokenBase =
|
|
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
|
|
const fixture = writeOAuthFixture({
|
|
[serverUrl]: {
|
|
tokens: {
|
|
access_token: "still-usable-access",
|
|
refresh_token: "old-refresh",
|
|
token_type: "Bearer",
|
|
},
|
|
clientInformation: { client_id: "cid", client_secret: "sec" },
|
|
serverMetadata: {
|
|
issuer: tokenBase,
|
|
token_endpoint: `${tokenBase}/token`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--use-stored-auth",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const last = server.getRecordedRequests().at(-1)!;
|
|
expect(last.headers?.authorization).toBe("Bearer still-usable-access");
|
|
} finally {
|
|
rmSync(fixture, { force: true });
|
|
await new Promise<void>((resolve) => tokenServer.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("--list-stored-auth", () => {
|
|
it("prints the stored server URLs and the resolved state path", async () => {
|
|
const fixture = writeOAuthFixture({
|
|
"https://a.example/mcp": {
|
|
tokens: { access_token: "t1", token_type: "Bearer" },
|
|
},
|
|
"https://b.example/mcp": { tokens: {} },
|
|
});
|
|
try {
|
|
const result = await runCli(["--list-stored-auth"], {
|
|
env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture },
|
|
});
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as {
|
|
oauthStatePath: string;
|
|
storedServerUrls: string[];
|
|
};
|
|
expect(out.oauthStatePath).toBe(fixture);
|
|
expect(out.storedServerUrls).toEqual(["https://a.example/mcp"]);
|
|
} finally {
|
|
rmSync(fixture, { force: true });
|
|
}
|
|
});
|
|
|
|
it("emits an empty list when the state file is absent", async () => {
|
|
const result = await runCli(["--list-stored-auth"], {
|
|
env: { MCP_INSPECTOR_OAUTH_STATE_PATH: "/no/such/file.json" },
|
|
});
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as { storedServerUrls: string[] };
|
|
expect(out.storedServerUrls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("--print-handoff", () => {
|
|
it("emits a JSON handoff block with deepLink, port-forward command, and the resolved state path", async () => {
|
|
const result = await runCli(
|
|
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
|
{
|
|
env: {
|
|
MCP_INSPECTOR_API_TOKEN: "tok123",
|
|
CLIENT_PORT: "16274",
|
|
MCP_SANDBOX_PORT: "16275",
|
|
MCP_STORAGE_DIR: "/tmp/inspector-storage",
|
|
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
|
|
},
|
|
},
|
|
);
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as {
|
|
serverUrl: string;
|
|
deepLink: string;
|
|
portForwardCmd: string;
|
|
oauthStatePath: string;
|
|
apiToken: string;
|
|
};
|
|
expect(out.serverUrl).toBe("https://x.example/mcp");
|
|
expect(out.deepLink).toContain("autoConnect=tok123");
|
|
expect(out.deepLink).toContain("serverUrl=https%3A%2F%2Fx.example%2Fmcp");
|
|
// Canonical #1576 deep-link: a `/mcp` server hands off `transport=http`.
|
|
expect(out.deepLink).toContain("transport=http");
|
|
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
|
|
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
|
expect(out.oauthStatePath).toBe(
|
|
join("/tmp/inspector-storage", "oauth.json"),
|
|
);
|
|
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"],
|
|
{ env: { MCP_INSPECTOR_API_TOKEN: "tok123" } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as { deepLink: string };
|
|
expect(out.deepLink).toContain("transport=sse");
|
|
expect(out.deepLink).not.toContain("transport=http");
|
|
});
|
|
|
|
it("honors an explicit --transport sse over the URL path", async () => {
|
|
const result = await runCli(
|
|
[
|
|
"--print-handoff",
|
|
"--server-url",
|
|
"https://x.example/mcp",
|
|
"--transport",
|
|
"sse",
|
|
],
|
|
{ env: { MCP_INSPECTOR_API_TOKEN: "tok123" } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as { deepLink: string };
|
|
expect(out.deepLink).toContain("transport=sse");
|
|
});
|
|
|
|
it("includes a note when MCP_INSPECTOR_API_TOKEN is unset", async () => {
|
|
const result = await runCli(
|
|
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
|
{ env: { MCP_INSPECTOR_API_TOKEN: "" } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const out = JSON.parse(result.stdout) as {
|
|
apiToken: string | null;
|
|
note?: string;
|
|
};
|
|
expect(out.apiToken).toBeNull();
|
|
expect(out.note).toContain("MCP_INSPECTOR_API_TOKEN is not set");
|
|
});
|
|
|
|
it("requires --server-url", async () => {
|
|
const result = await runCli(["--print-handoff"]);
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toContain("--print-handoff requires --server-url");
|
|
});
|
|
});
|
|
|
|
describe("--wait-for-auth", () => {
|
|
let server: ReturnType<typeof createTestServerHttp>;
|
|
let serverUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
await server.start();
|
|
serverUrl = server.url;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
});
|
|
|
|
it("polls until the token appears, then proceeds with it", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "inspector-cli-wait-"));
|
|
const file = join(dir, "oauth.json");
|
|
// Write the token after a short delay so the first poll misses.
|
|
setTimeout(() => {
|
|
writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
servers: {
|
|
[normalizeServerUrl(serverUrl)]: {
|
|
tokens: { access_token: "waited-tok", token_type: "Bearer" },
|
|
},
|
|
},
|
|
idpSessions: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
}, 200);
|
|
try {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--wait-for-auth",
|
|
"5",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } },
|
|
);
|
|
expectCliSuccess(result);
|
|
const last = server.getRecordedRequests().at(-1)!;
|
|
expect(last.headers?.authorization).toBe("Bearer waited-tok");
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("times out with exit 3 (AUTH_REQUIRED) when no token appears", async () => {
|
|
const result = await runCli(
|
|
[
|
|
"--transport",
|
|
"http",
|
|
"--server-url",
|
|
serverUrl,
|
|
"--wait-for-auth",
|
|
"1",
|
|
"--method",
|
|
"tools/list",
|
|
],
|
|
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: "/no/such/file.json" } },
|
|
);
|
|
expect(result.exitCode).toBe(3);
|
|
const env = JSON.parse(result.stderr.trim()) as {
|
|
error: { code: string };
|
|
};
|
|
expect(env.error.code).toBe("auth_wait_timeout");
|
|
});
|
|
|
|
it("rejects a non-positive timeout", async () => {
|
|
const result = await runCli([
|
|
"--server-url",
|
|
"https://x.example/mcp",
|
|
"--wait-for-auth",
|
|
"0",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toContain("positive number of seconds");
|
|
});
|
|
});
|