CLI: catalog listing, auth flags, and OAuth browser UX (#1782)

* feat(cli): catalog listing, auth flags, and OAuth browser UX

Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs

Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate OAuth browser open and harden #1782 review follow-ups

Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): silence disarmed OAuth URLs and round-3 review nits

Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): cover withArmedAutoOpen for the ≥90 branch gate

CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits

Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits

Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits

Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)

Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on step-up without stdin (round-8)

Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allow piped step-up answers; bound silent pipes (round-9)

Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep partial piped step-up answers (round-10)

Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): tighten step-up timeout messaging and TTY test (round-11)

Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bob Dickinson 2026-07-26 13:55:25 -07:00 • committed by GitHub
commit 33e88eac9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,22 +7,69 @@ import {
handleCliAuthRecoveryRequired,
isStandardOAuthStepUp,
runCliInteractiveOAuth,
assertInteractiveOAuthAllowed,
withCliAuthRecoveryRetry,
STEP_UP_PIPE_TIMEOUT_MS,
} from "../src/cliOAuth.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { createInterface } from "node:readline/promises";
// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
// stdin via node:readline/promises. Mock the module so the default path can be
// exercised deterministically without real TTY input.
const { mockQuestion, mockClose } = vi.hoisted(() => ({
mockQuestion: vi.fn(),
mockClose: vi.fn(),
}));
const { mockQuestion, mockClose, createMockReadline } = vi.hoisted(() => {
const mockQuestion = vi.fn();
const mockClose = vi.fn();
function createMockReadline(options?: {
question?: () => Promise<string>;
/** When set, emit `close` on the next microtask (EOF, no partial line). */
emitClose?: boolean;
/**
* Emit a `line` then `close` on the next microtask — models
* `printf 'y' | …` (partial last line without trailing newline).
*/
emitLineThenClose?: string;
}) {
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const rl = {
question: options?.question ?? mockQuestion,
close: mockClose,
on(event: string, cb: (...args: unknown[]) => void) {
let set = listeners.get(event);
if (!set) {
set = new Set();
listeners.set(event, set);
}
set.add(cb);
return rl;
},
once(event: string, cb: (...args: unknown[]) => void) {
// Match EventEmitter.removeListener(originalFn) after once(fn).
return rl.on(event, cb);
},
removeListener(event: string, cb: (...args: unknown[]) => void) {
listeners.get(event)?.delete(cb);
return rl;
},
emit(event: string, ...args: unknown[]) {
for (const cb of [...(listeners.get(event) ?? [])]) cb(...args);
},
};
if (options?.emitLineThenClose !== undefined) {
const line = options.emitLineThenClose;
queueMicrotask(() => {
rl.emit("line", line);
rl.emit("close");
});
} else if (options?.emitClose) {
queueMicrotask(() => rl.emit("close"));
}
return rl;
}
return { mockQuestion, mockClose, createMockReadline };
});
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: mockQuestion,
close: mockClose,
})),
createInterface: vi.fn(() => createMockReadline()),
}));
const CALLBACK_URL_CONFIG = {
@ -31,6 +78,19 @@ const CALLBACK_URL_CONFIG = {
pathname: "/oauth/callback",
};
const OAUTH_HTTP_CONFIG = {
type: "streamable-http",
url: "https://as.example/mcp",
} as MCPServerConfig;
const STDIO_CONFIG = {
type: "stdio",
command: "x",
} as MCPServerConfig;
/** Unit tests run without a TTY; opt into interactive OAuth explicitly. */
const INTERACTIVE = { isTTY: true as const };
describe("cliOAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
@ -82,6 +142,37 @@ describe("cliOAuth", () => {
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
});
it("runCliInteractiveOAuth arms autoOpenControl only for the interactive flow", async () => {
const autoOpenControl = { armed: false };
let armedDuringCall = false;
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockImplementation(
async () => {
armedDuringCall = autoOpenControl.armed;
return { kind: "success" };
},
);
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await runCliInteractiveOAuth(
client,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ autoOpenControl },
);
expect(armedDuringCall).toBe(true);
expect(autoOpenControl.armed).toBe(false);
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
});
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
const challenge = {
reason: "insufficient_scope" as const,
@ -130,7 +221,7 @@ describe("cliOAuth", () => {
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => false,
{ confirmStepUp: async () => false, isTTY: true },
),
).rejects.toThrow("Step-up authorization declined.");
@ -160,7 +251,7 @@ describe("cliOAuth", () => {
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => true,
{ confirmStepUp: async () => true, isTTY: true },
);
expect(runSpy).toHaveBeenCalledWith(
@ -224,7 +315,10 @@ describe("cliOAuth", () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "success",
});
const connect = vi.fn().mockResolvedValue(undefined);
const client = {
connect,
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
@ -241,14 +335,16 @@ describe("cliOAuth", () => {
const result = await withCliAuthRecoveryRetry(
client,
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ enterpriseManaged: true },
fn,
async () => true,
{ confirmStepUp: async () => true, isTTY: true },
);
expect(result).toBe("ok");
expect(connect).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledTimes(2);
});
@ -272,14 +368,15 @@ describe("cliOAuth", () => {
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
// Omitting the confirmStepUp argument exercises the default
// confirmStepUpFromStdin, which reads from the mocked readline interface.
// Omitting confirmStepUp exercises the default confirmStepUpFromStdin,
// which reads from the mocked readline interface.
await handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
);
expect(mockQuestion).toHaveBeenCalled();
@ -299,6 +396,7 @@ describe("cliOAuth", () => {
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
);
expect(runSpy).toHaveBeenCalled();
@ -315,19 +413,177 @@ describe("cliOAuth", () => {
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
expect(mockClose).toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
it("declines when readline question rejects", async () => {
mockQuestion.mockRejectedValue(new Error("stdin closed"));
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
await expect(
handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
expect(runSpy).not.toHaveBeenCalled();
});
it("declines when stdin EOF closes the readline interface before an answer", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
vi.mocked(createInterface).mockImplementationOnce(
() =>
createMockReadline({
question: () => new Promise(() => {}),
emitClose: true,
}) as unknown as ReturnType<typeof createInterface>,
);
await expect(
handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
expect(runSpy).not.toHaveBeenCalled();
});
it("accepts a partial last line without trailing newline (printf y | …)", async () => {
// On stream end, readline emits the buffered partial line then close
// without settling question() — close must use the captured line.
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
vi.mocked(createInterface).mockImplementationOnce(
() =>
createMockReadline({
question: () => new Promise(() => {}),
emitLineThenClose: "y",
}) as unknown as ReturnType<typeof createInterface>,
);
await handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
);
expect(runSpy).toHaveBeenCalled();
});
it("accepts a piped y when stdin is non-TTY (echo y | …)", async () => {
// Force-admit interactive OAuth without a TTY; the confirmer still reads
// the piped answer (round-9: do not hard-require stdin.isTTY).
vi.stubEnv("MCP_AUTO_OPEN_ENABLED", "true");
mockQuestion.mockResolvedValue("y");
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
try {
await handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
{ isTTY: false },
);
expect(runSpy).toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
}
});
it("fails with auth_required when a non-TTY stdin never answers (pipe timeout)", async () => {
mockQuestion.mockImplementation(() => new Promise(() => {}));
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
await expect(
handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
{ isTTY: true, stepUpPromptTimeoutMs: 20 },
),
).rejects.toMatchObject({
exitCode: 3,
message: expect.stringMatching(/no answer on stdin within 20ms/),
});
expect(runSpy).not.toHaveBeenCalled();
});
it("does not apply the pipe timeout when stdin is a TTY", async () => {
vi.useFakeTimers();
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
get: () => true,
});
let resolveQuestion!: (value: string) => void;
mockQuestion.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveQuestion = resolve;
}),
);
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
try {
const pending = handleCliAuthRecoveryRequired(
clientNeedingStepUp(),
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
INTERACTIVE,
);
let settled = false;
void pending.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.advanceTimersByTimeAsync(STEP_UP_PIPE_TIMEOUT_MS + 60_000);
await Promise.resolve(); // flush microtasks from any premature settle
expect(settled).toBe(false);
resolveQuestion("y");
await pending;
expect(runSpy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
else delete (process.stdin as { isTTY?: boolean }).isTTY;
}
});
});
describe("connectInspectorWithOAuth recovery branch", () => {
const oauthServerConfig = {
type: "streamable-http",
url: "https://as.example/mcp",
} as MCPServerConfig;
const oauthServerConfig = OAUTH_HTTP_CONFIG;
it("resumes without re-auth when storage already satisfies the challenge", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
@ -381,6 +637,8 @@ describe("cliOAuth", () => {
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
INTERACTIVE,
);
expect(connect).toHaveBeenCalledTimes(2);
@ -407,6 +665,8 @@ describe("cliOAuth", () => {
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
INTERACTIVE,
);
expect(client.disconnect).toHaveBeenCalled();
@ -453,5 +713,479 @@ describe("cliOAuth", () => {
),
).rejects.toThrow("nope (401)");
});
it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const connect = vi.fn().mockRejectedValue(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "token_expired",
}),
);
const client = {
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};
await expect(
connectInspectorWithOAuth(
client,
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(runSpy).not.toHaveBeenCalled();
});
it("under --stored-auth-only reconnects when the store already satisfies", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const connect = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "token_expired" },
),
)
.mockResolvedValueOnce(undefined);
const client = {
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
await connectInspectorWithOAuth(
client,
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
);
expect(connect).toHaveBeenCalledTimes(2);
expect(runSpy).not.toHaveBeenCalled();
});
it("fails stored-auth-only on plain unauthorized errors", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const connect = vi
.fn()
.mockRejectedValue(new Error("Connection failed for server (401)"));
const client = {
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
await expect(
connectInspectorWithOAuth(
client,
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(runSpy).not.toHaveBeenCalled();
});
});
it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => {
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(false);
const fn = vi.fn().mockRejectedValue(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "token_expired",
}),
);
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied,
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(checkAuthChallengeSatisfied).toHaveBeenCalledOnce();
});
it("withCliAuthRecoveryRetry under storedAuthOnly retries when the store satisfies the challenge", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const challenge = {
reason: "token_expired" as const,
};
const fn = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
challenge,
),
)
.mockResolvedValueOnce("ok");
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
const result = await withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied,
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ storedAuthOnly: true },
);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
expect(checkAuthChallengeSatisfied).toHaveBeenCalledWith(challenge);
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry recovers plain unauthorized errors like connect", async () => {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const connect = vi.fn().mockResolvedValue(undefined);
const disconnect = vi.fn().mockResolvedValue(undefined);
const callOrder: string[] = [];
connect.mockImplementation(async () => {
callOrder.push("connect");
});
const fn = vi
.fn()
.mockImplementationOnce(async () => {
callOrder.push("fn1");
throw new Error("RPC failed (401)");
})
.mockImplementationOnce(async () => {
callOrder.push("fn2");
return "ok";
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const result = await withCliAuthRecoveryRetry(
{
connect,
disconnect,
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
INTERACTIVE,
);
expect(result).toBe("ok");
expect(disconnect).toHaveBeenCalledOnce();
expect(runSpy).toHaveBeenCalledOnce();
expect(connect).toHaveBeenCalledOnce();
expect(callOrder).toEqual(["fn1", "connect", "fn2"]);
expect(stderrSpy).toHaveBeenCalledWith(
"Authorization complete. Retrying…\n",
);
});
it("withCliAuthRecoveryRetry fails stored-auth-only on plain unauthorized errors", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry rethrows unauthorized errors for non-OAuth configs", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const err = new Error("proxied backend failed with status (401)");
const fn = vi.fn().mockRejectedValue(err);
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
STDIO_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
),
).rejects.toBe(err);
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry rethrows unrelated errors unchanged", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const err = new Error("something else went wrong");
const fn = vi.fn().mockRejectedValue(err);
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
),
).rejects.toBe(err);
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry stored-auth-only uses a fallback message for non-Error 401s", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const fn = vi.fn().mockRejectedValue({ status: 401 });
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({
exitCode: 3,
message: expect.stringMatching(/--stored-auth-only/),
});
expect(runSpy).not.toHaveBeenCalled();
});
it("assertInteractiveOAuthAllowed fails fast when neither stream is a TTY", () => {
expect(() => assertInteractiveOAuthAllowed({ isTTY: false })).toThrow(
/Interactive OAuth requires a TTY on stdin or stderr/,
);
});
it("assertInteractiveOAuthAllowed admits when isTTY override is true (stdin||stderr path)", () => {
expect(() => assertInteractiveOAuthAllowed({ isTTY: true })).not.toThrow();
});
it("assertInteractiveOAuthAllowed admits when stdin is a TTY even if stderr is not", () => {
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
get: () => true,
});
Object.defineProperty(process.stderr, "isTTY", {
configurable: true,
get: () => false,
});
try {
expect(() => assertInteractiveOAuthAllowed()).not.toThrow();
} finally {
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
else delete (process.stdin as { isTTY?: boolean }).isTTY;
if (stderrDesc)
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
else delete (process.stderr as { isTTY?: boolean }).isTTY;
}
});
it("assertInteractiveOAuthAllowed fails when both stdin and stderr are non-TTY", () => {
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
get: () => false,
});
Object.defineProperty(process.stderr, "isTTY", {
configurable: true,
get: () => false,
});
try {
expect(() => assertInteractiveOAuthAllowed()).toThrow(
/Interactive OAuth requires a TTY on stdin or stderr/,
);
} finally {
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
else delete (process.stdin as { isTTY?: boolean }).isTTY;
if (stderrDesc)
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
else delete (process.stderr as { isTTY?: boolean }).isTTY;
}
});
it("runCliInteractiveOAuth is quiet on already_authorized", async () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "already_authorized",
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await runCliInteractiveOAuth(
{
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
);
expect(stderrSpy).not.toHaveBeenCalledWith("Authorization complete.\n");
});
it("connectInspectorWithOAuth stored-auth-only uses fallback when AuthRecovery message is empty", async () => {
const err = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "token_expired" },
);
Object.defineProperty(err, "message", { value: "" });
const connect = vi.fn().mockRejectedValue(err);
await expect(
connectInspectorWithOAuth(
{
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({
exitCode: 3,
message: expect.stringMatching(/--stored-auth-only/),
});
});
it("withCliAuthRecoveryRetry refuses interactive OAuth on a non-TTY", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
await expect(
withCliAuthRecoveryRetry(
{
connect: vi.fn(),
disconnect: vi.fn(),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ isTTY: false },
),
).rejects.toMatchObject({
exitCode: 3,
message: expect.stringMatching(/--stored-auth-only/),
});
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry allows non-TTY interactive OAuth when MCP_AUTO_OPEN_ENABLED=true", async () => {
const prev = process.env.MCP_AUTO_OPEN_ENABLED;
process.env.MCP_AUTO_OPEN_ENABLED = "true";
try {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const connect = vi.fn().mockResolvedValue(undefined);
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("RPC failed (401)"))
.mockResolvedValueOnce("ok");
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const result = await withCliAuthRecoveryRetry(
{
connect,
disconnect: vi.fn().mockResolvedValue(undefined),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
},
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
{ isTTY: false },
);
expect(result).toBe("ok");
expect(runSpy).toHaveBeenCalledOnce();
expect(connect).toHaveBeenCalledOnce();
} finally {
if (prev === undefined) {
delete process.env.MCP_AUTO_OPEN_ENABLED;
} else {
process.env.MCP_AUTO_OPEN_ENABLED = prev;
}
}
});
});