inspector/clients/cli/__tests__/stored-auth.test.ts
cliffhall 93f3fb64f2 cli: reconcile --print-handoff deep-link with #1576 canonical format (#1666)
Closes #1666

#1575 shipped `--print-handoff` with an interim deep-link shape (hardcoded
`transport: "http"`, marked `TODO(#1576)`). Now that #1576 owns the canonical
`?serverUrl&transport&autoConnect` format, `--print-handoff` emits exactly it:

- New `deepLinkTransport(serverUrl, transport)` helper derives `http`|`sse`
  from the resolved server — explicit `--transport` wins, else auto-detect from
  the URL path (`/sse` → sse), else default `http`. Unlike the core resolver it
  defaults instead of throwing (the handoff is best-effort; the web parser
  defaults an unknown transport to http too).
- `buildHandoff` takes the transport, emits the normalized serverUrl +
  derived transport in the query, and keeps `autoConnect=<token>` (which #1576
  adopted as the canonical CSRF gate, so the interim guess was already correct).
- Removed the `TODO(#1576)` marker; updated the doc comment + CLI README.

Tests: handoff emits `transport=http` for `/mcp`, `transport=sse` for `/sse`
and for explicit `--transport sse`; `deepLinkTransport` unit tests cover the
explicit/auto/default/stdio/unparseable branches. CLI coverage gate green
(cli.ts 100/92.8/100/100).

Part of the #1579 decomposition (Wave 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-11 23:02:45 -04:00

430 lines
14 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
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 } 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("--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}`);
});
});
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("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");
});
});