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
3 changes: 2 additions & 1 deletion clients/mcpdo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ mcpdo servers/show test-stdio --config path/to/mcp.json
mcpdo connect test-stdio --config path/to/mcp.json
mcpdo connect my-http --config path/to/mcp.json --relogin # (-r) ignore stored OAuth; login only if auth required
mcpdo auth/list
mcpdo auth/show https://example.com/mcp # decoded access-token claims (issuer, audience, scopes, expiry) — never the token
mcpdo auth/clear https://example.com/mcp
mcpdo auth/clear hosted-everything # or a catalog/connection name (from auth/list "known as")
mcpdo auth/clear --all --yes
Expand Down Expand Up @@ -105,7 +106,7 @@ token. For a hard boundary, use OS-level isolation (separate user, container).

**Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope.

**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `auth/list` annotates each stored URL with the catalog/connection names it is "known as" and marks `● live` when a connection currently holds it, so the raw store URLs correlate to the servers you actually use. EMA IdP login records (keyed `ema-idp:<issuer>` in the store) lead with their bare issuer URL and carry a trailing `enterprise IdP login` marker (parallel to `known as:`) rather than showing as unnamed servers. `auth/clear` accepts either a store URL **or** one of those friendly names (a name that maps to no URL — e.g. a stdio server — or to more than one URL is rejected with guidance); an EMA IdP login clears by its bare issuer URL (or its raw `ema-idp:` key). `--relogin` (`-r`) clears any URL-keyed store entry before connect (no-op for stdio). To force a clean logged-out state from an already-open connection, `disconnect <name> --clear-auth` (`-c`) tears the connection down **and** clears its stored tokens in one step, so the next plain `connect` re-triggers sign-in — no-op for stdio / servers with no stored entry. Non-TTY `connect` exits 0 with `pendingAuth: true` and an `authUrl` to relay; after the user signs in, any real command completes the connection, `connections/show` completes it too, and `connections/list` marks the entry `pendingAuthSignedIn` ("signed in — completing on next use") without dialing.
**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `auth/list` annotates each stored URL with the catalog/connection names it is "known as" and marks `● live` when a connection currently holds it, so the raw store URLs correlate to the servers you actually use. EMA IdP login records (keyed `ema-idp:<issuer>` in the store) lead with their bare issuer URL and carry a trailing `enterprise IdP login` marker (parallel to `known as:`) rather than showing as unnamed servers. `auth/clear` accepts either a store URL **or** one of those friendly names (a name that maps to no URL — e.g. a stdio server — or to more than one URL is rejected with guidance); an EMA IdP login clears by its bare issuer URL (or its raw `ema-idp:` key). `auth/show` takes the same key forms as `auth/clear` and summarises the stored access token without printing it: for a JWT, its decoded claims (issuer, audience, subject, client, scopes, issued-at and expiry relative to now — the signature is **not** verified); for an opaque token, the `scope` and `expires_in` the token response carried. `--relogin` (`-r`) clears any URL-keyed store entry before connect (no-op for stdio). To force a clean logged-out state from an already-open connection, `disconnect <name> --clear-auth` (`-c`) tears the connection down **and** clears its stored tokens in one step, so the next plain `connect` re-triggers sign-in — no-op for stdio / servers with no stored entry. Non-TTY `connect` exits 0 with `pendingAuth: true` and an `authUrl` to relay; after the user signs in, any real command completes the connection, `connections/show` completes it too, and `connections/list` marks the entry `pendingAuthSignedIn` ("signed in — completing on next use") without dialing.

See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list.

Expand Down
37 changes: 37 additions & 0 deletions clients/mcpdo/__tests__/auth-names-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const callDaemon = vi.fn();
const listServerEntries = vi.fn();
const listStoredAuth = vi.fn();
const clearStoredAuth = vi.fn();
const showStoredAuth = vi.fn();

// Stub only the three reaches of the auth commands; importOriginal keeps the
// rest (including the real normalizeServerUrl / buildAuthNameIndex) intact, so
Expand All @@ -33,6 +34,7 @@ vi.mock("../src/connection/stored-auth.js", async (importOriginal) => ({
>()),
listStoredAuth: (...args: unknown[]) => listStoredAuth(...args),
clearStoredAuth: (...args: unknown[]) => clearStoredAuth(...args),
showStoredAuth: (...args: unknown[]) => showStoredAuth(...args),
}));

const { runMcp } = await import("./helpers/mcp-runner.js");
Expand All @@ -58,6 +60,10 @@ beforeEach(() => {
servers: [],
});
clearStoredAuth.mockImplementation((url: string) => Promise.resolve({ url }));
showStoredAuth.mockReset();
showStoredAuth.mockImplementation((url: string) =>
Promise.resolve({ url, hasTokens: false, hasRefreshToken: false }),
);
});

describe("auth/list friendly-name annotation", () => {
Expand Down Expand Up @@ -236,3 +242,34 @@ describe("auth/clear by friendly name", () => {
expect(clearStoredAuth).not.toHaveBeenCalled();
});
});

// #2845 — auth/show resolves its key exactly as auth/clear does.
describe("auth/show by friendly name", () => {
it("resolves a catalog name to its URL", async () => {
listServerEntries.mockResolvedValue([
{ name: "hosted", type: "streamable-http", detail: URL_A },
]);
const result = await runMcp(["auth/show", "hosted", "--format", "json"]);
expect(showStoredAuth).toHaveBeenCalledWith(URL_A);
expect(JSON.parse(result.stdout.trim())).toEqual({
url: URL_A,
hasTokens: false,
hasRefreshToken: false,
shownByName: "hosted",
});

const human = await runMcp(["auth/show", "hosted"]);
expect(human.stdout).toContain("(hosted)");
expect(human.stdout).toContain("No stored access token.");
});

it("says 'show' in the stdio-name error", async () => {
listServerEntries.mockResolvedValue([
{ name: "local", type: "stdio", detail: "node server.js" },
]);
const result = await runMcp(["auth/show", "local"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("no stored OAuth entry to show");
expect(showStoredAuth).not.toHaveBeenCalled();
});
});
141 changes: 141 additions & 0 deletions clients/mcpdo/__tests__/connection-stored-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
clearStoredAuthForRelogin,
listStoredAuth,
resolveStoredAuthKey,
showStoredAuth,
} from "../src/connection/stored-auth.js";
import { CliExitCodeError } from "@inspector/core/cli/error-handler.js";
import { defaultSecretStore } from "@inspector/core/auth/node/secret-store-selection.js";
Expand Down Expand Up @@ -83,6 +84,7 @@ const FIXTURE_URLS = [
"https://empty.example/mcp",
"https://issuer-empty.example/mcp",
"https://multi.example/mcp",
"https://jwt.example/mcp",
];

/**
Expand Down Expand Up @@ -327,3 +329,142 @@ describe("mcp auth/list and auth/clear", () => {
expect(human.stdout).toMatch(/Stored auth/);
});
});

// #2845 — the stored access token's claims, never the token itself.
describe("mcp auth/show", () => {
let dir: string | undefined;
const nowS = Math.floor(Date.now() / 1000);
// The command reads the real clock. A day-scale offset mid-way through an
// hour bucket keeps the rendered "2d 12h" stable for ~30 minutes of test
// delay instead of flipping at a minute boundary (Copilot, #2891).
const SPAN = 2 * 86_400 + 12 * 3600 + 30 * 60;
const b64url = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const JWT = `${b64url({ alg: "RS256" })}.${b64url({
iss: "https://as.example/",
aud: "https://jwt.example/mcp",
sub: "user-7",
scope: "tools:read",
iat: nowS - 60,
exp: nowS + SPAN,
})}.signature-not-shown`;

afterEach(async () => {
resetNodeOAuthStorageCache();
await purgeFixtureSecrets();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});

function fixture(): string {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-show-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({
servers: {
"https://jwt.example/mcp": {
tokens: {
access_token: JWT,
token_type: "Bearer",
refresh_token: "r",
},
},
"https://other.example/mcp": {
tokens: {
access_token: "opaque",
token_type: "Bearer",
scope: "a b",
expires_in: 3600,
},
},
"https://empty.example/mcp": { codeVerifier: "cv" },
},
idpSessions: {},
}),
"utf8",
);
resetNodeOAuthStorageCache();
return file;
}

it("summarises a JWT access token as JSON without the token", async () => {
const file = fixture();
const result = await runMcp(
["auth/show", "https://jwt.example/mcp", "--format", "json"],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } },
);
expectCliSuccess(result);
expect(result.stdout).not.toContain(JWT);
expect(JSON.parse(result.stdout)).toEqual({
url: "https://jwt.example/mcp",
hasTokens: true,
hasRefreshToken: true,
accessToken: {
format: "jwt",
issuer: "https://as.example/",
audience: ["https://jwt.example/mcp"],
subject: "user-7",
scopes: ["tools:read"],
issuedAt: nowS - 60,
expiresAt: nowS + SPAN,
},
});
});

it("renders claims, an opaque token and a token-less entry as text", async () => {
const file = fixture();
const env = { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } };
const jwt = await runMcp(["auth/show", "https://jwt.example/mcp"], env);
expectCliSuccess(jwt);
expect(jwt.stdout).toContain("JWT (signature not verified)");
expect(jwt.stdout).toMatch(/Audience:\s+https:\/\/jwt\.example\/mcp/);
expect(jwt.stdout).toMatch(/Expires:\s+\S+ \(in 2d 12h\)/);
expect(jwt.stdout).toMatch(/Refresh:\s+stored/);
expect(jwt.stdout).not.toContain("signature-not-shown");

const opaque = await runMcp(
["auth/show", "https://other.example/mcp"],
env,
);
expectCliSuccess(opaque);
expect(opaque.stdout).toContain("Opaque (not a JWT)");
expect(opaque.stdout).toMatch(/Scopes:\s+a, b/);
expect(opaque.stdout).toContain("1h from issuance");
expect(opaque.stdout).toMatch(/Refresh:\s+none/);

const empty = await runMcp(["auth/show", "https://empty.example/mcp"], env);
expectCliSuccess(empty);
expect(empty.stdout).toContain("No stored access token.");
});

it("rejects a blank key", async () => {
const file = fixture();
const result = await runMcp(["auth/show", " "], {
env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file },
});
expectCliFailure(result);
expect(result.stderr).toMatch(/auth\/show requires/);
});

it("showStoredAuth reports an entry with no tokens", async () => {
const file = fixture();
const prev = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
try {
resetNodeOAuthStorageCache();
await expect(
showStoredAuth("https://empty.example/mcp"),
).resolves.toEqual({
url: "https://empty.example/mcp",
hasTokens: false,
hasRefreshToken: false,
});
} finally {
if (prev === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prev;
}
});
});
12 changes: 12 additions & 0 deletions clients/mcpdo/src/connection/format-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
formatAppInfoHuman,
formatAppInfoListHuman,
formatAuthListHuman,
formatAuthShowHuman,
formatEmaStatusHuman,
formatRpcResultHuman,
formatServersListHuman,
Expand All @@ -30,6 +31,7 @@ import {
} from "./format-human.js";
import { isSafeLinkTarget, sanitizeDeep, sanitizeText } from "./sanitize.js";
import { PLAIN, type Style } from "@inspector/core/cli/style.js";
import type { StoredAuthShow } from "./stored-auth.js";

type JsonObject = Record<string, unknown>;

Expand Down Expand Up @@ -130,6 +132,12 @@ export type ConnectionWriteKind =
clearedByName?: string;
};
}
| {
kind: "auth/show";
result: StoredAuthShow & { shownByName?: string };
/** "Now" for the human output's relative times; not part of the JSON. */
nowMs: number;
}
| {
kind: "auth/ema-status";
status: {
Expand Down Expand Up @@ -266,6 +274,8 @@ function jsonPayload(payload: ConnectionWriteKind): unknown {
return payload.list;
case "auth/clear":
return payload.result;
case "auth/show":
return payload.result;
case "auth/ema-status":
return payload.status;
case "auth/ema-login":
Expand Down Expand Up @@ -434,6 +444,8 @@ function humanPayload(
? style.dim(` (${String(payload.result.clearedByName)})`)
: ""
}`;
case "auth/show":
return formatAuthShowHuman(payload.result, payload.nowMs, style);
case "auth/ema-status":
return formatEmaStatusHuman(payload.status, style);
case "auth/ema-login":
Expand Down
39 changes: 39 additions & 0 deletions clients/mcpdo/src/connection/format-human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { isSafeLinkTarget } from "./sanitize.js";
import { parseFormSchema } from "./form-schema.js";
import type { ElicitationPendingInfo } from "../daemon/protocol.js";
import type { LoadSummary } from "@inspector/core/cli/handlers/load-test.js";
import {
accessTokenDetailLines,
type AccessTokenSummary,
} from "@inspector/core/auth/accessTokenClaims.js";

type JsonObject = Record<string, unknown>;

Expand Down Expand Up @@ -521,6 +525,41 @@ export function formatAuthListHuman(
return lines.join("\n");
}

/**
* Format auth/show (#2845): the stored access token's decoded claims, never the
* token itself. Wording is shared with the TUI Auth tab via
* `accessTokenDetailLines`.
*/
export function formatAuthShowHuman(
result: {
url: string;
hasTokens: boolean;
hasRefreshToken: boolean;
accessToken?: AccessTokenSummary;
shownByName?: string;
},
nowMs: number,
style: Style = PLAIN,
): string {
const title = `${heading(style, "Stored auth")} ${code(style, result.url)}${
result.shownByName ? style.dim(` (${result.shownByName})`) : ""
}`;
if (!result.hasTokens || !result.accessToken) {
return [title, style.dim("No stored access token.")].join("\n");
}
const rows = accessTokenDetailLines(result.accessToken, nowMs);
const width = Math.max(...rows.map((r) => r.label.length));
return [
title,
...rows.map(
(r) => ` ${style.dim(`${r.label}:`.padEnd(width + 1))} ${r.value}`,
),
` ${style.dim("Refresh:".padEnd(width + 1))} ${
result.hasRefreshToken ? "stored" : "none"
}`,
].join("\n");
}

/** Format auth/ema-status. */
export function formatEmaStatusHuman(
status: {
Expand Down
Loading
Loading