test: close ≥90% per-file coverage gaps for mid-session auth

The mid-session auth work passed `validate`/CI but failed the local-only
`npm run test:coverage` per-file gate (≥90% lines/statements/functions/
branches) on 12 files. Add targeted unit/integration tests so every changed
file clears the gate; annotate provably-dead defensive branches with justified
`/* v8 ignore */` comments rather than relaxing the gate.

Files brought to ≥90 on all four dimensions:
- core/auth: mcpAuth, oauthUx, utils, challenge, node/runner-interactive-oauth
- core/mcp: oauthManager, inspectorClient, node/authChallengeFetch,
  remote/remoteClientTransport, remote/node/{remote-session,server}
- clients/web/src/utils: oauthResume, browserTabVisibility

`npm run test:coverage` now passes (3575 tests, 0 threshold failures);
`npm run validate` green across all four clients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc
This commit is contained in:
cliffhall 2026-07-03 12:54:37 -04:00
commit f4b6095719
4 changed files with 3447 additions and 37 deletions

View file

@ -0,0 +1,269 @@
/**
* Supplemental coverage for createRemoteApp's /api/mcp/* routes
* (core/mcp/remote/node/server.ts), targeting branches the broader e2e
* suites (transport.test.ts, connect-crash.test.ts) don't reach:
*
* - POST /api/mcp/connect: connect-time AuthChallengeError (401 upstream)
* - POST /api/mcp/send: transport-dead short-circuit
* - POST /api/mcp/disconnect: unknown sessionId (no-op, still 200)
* - POST /api/mcp/auth-state: every guard branch (missing fields, unknown
* session, dead transport, no OAuth provider on the session, success)
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { createServer } from "node:http";
import type { Server } from "node:http";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
interface Harness {
baseUrl: string;
server: ServerType;
}
async function start(): Promise<Harness> {
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
initialConfig: { defaultEnvironment: {} },
});
return new Promise((resolve, reject) => {
const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server });
},
);
server.on("error", reject);
});
}
async function stop(h: Harness): Promise<void> {
await new Promise<void>((resolve) => h.server.close(() => resolve()));
}
async function connect(
h: Harness,
config: MCPServerConfig,
authState?: { oauthTokens: { access_token: string; token_type: string } },
): Promise<Response> {
return fetch(`${h.baseUrl}/api/mcp/connect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config, ...(authState && { authState }) }),
});
}
/** A raw HTTP server that returns 401 for every request (no MCP semantics). */
async function startUnauthorizedUpstream(): Promise<{
url: string;
server: Server;
}> {
const server = createServer((_req, res) => {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "unauthorized" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
return { url: `http://127.0.0.1:${port}/mcp`, server };
}
/** Connect a stdio session whose process crashes almost immediately, then
* give the onclose handler time to mark the session's transport dead. */
async function connectDeadSession(h: Harness): Promise<string> {
const config: MCPServerConfig = {
type: "stdio",
command: process.execPath,
args: ["-e", "process.stderr.write('dying\\n'); process.exit(1);"],
};
const res = await connect(h, config);
expect(res.status).toBe(200);
const { sessionId } = (await res.json()) as { sessionId: string };
// Give the subprocess time to exit and the transport's onclose handler
// to mark the session dead (mirrors connect-crash.test.ts's technique).
await new Promise((resolve) => setTimeout(resolve, 300));
return sessionId;
}
describe("server.ts /api/mcp/* branch coverage", () => {
let h: Harness;
beforeEach(async () => {
h = await start();
});
afterEach(async () => {
await stop(h);
});
describe("POST /api/mcp/connect", () => {
it("returns 500 (not 401) when the upstream SSE connection is rejected for a non-auth reason", async () => {
// The SDK's SSEClientTransport wraps every connection failure — including
// a 401 — in its own SseError, so `err instanceof AuthChallengeError` at
// the connect catch site is unreachable via the real SSE/streamable-http
// SDK transports today (streamable-http's start() makes no network call
// at all; SSE's start() discards the thrown error's subclass). This test
// instead pins the *reachable* generic-failure branch: a non-401 refusal
// still surfaces as a 500 with the wrapped message, not a misleading 401.
const upstream = await startUnauthorizedUpstream();
try {
const res = await connect(h, {
type: "sse",
url: upstream.url,
});
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/Failed to start transport/);
} finally {
await new Promise<void>((r) => upstream.server.close(() => r()));
}
});
});
describe("POST /api/mcp/send", () => {
it("short-circuits with a transport_error when the session's transport is already dead", async () => {
const sessionId = await connectDeadSession(h);
const res = await fetch(`${h.baseUrl}/api/mcp/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
kind?: string;
error?: string;
};
expect(body.ok).toBe(false);
expect(body.kind).toBe("transport_error");
expect(typeof body.error).toBe("string");
});
});
describe("POST /api/mcp/disconnect", () => {
it("returns ok:true as a no-op when the sessionId is unknown", async () => {
const res = await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: "does-not-exist" }),
});
expect(res.status).toBe(200);
expect((await res.json()).ok).toBe(true);
});
});
describe("POST /api/mcp/auth-state", () => {
async function postAuthState(body: unknown): Promise<Response> {
return fetch(`${h.baseUrl}/api/mcp/auth-state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
it("returns 400 when sessionId is missing", async () => {
const res = await postAuthState({
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(
/Missing sessionId or authState/,
);
});
it("returns 400 when authState is missing", async () => {
const res = await postAuthState({ sessionId: "some-session" });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(
/Missing sessionId or authState/,
);
});
it("returns 400 on invalid JSON body", async () => {
const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/Invalid JSON body/);
});
it("returns 404 when the session is not found", async () => {
const res = await postAuthState({
sessionId: "unknown-session-id",
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
});
expect(res.status).toBe(404);
expect((await res.json()).error).toMatch(/Session not found/);
});
it("returns a transport_error when the session's transport is already dead", async () => {
const sessionId = await connectDeadSession(h);
const res = await postAuthState({
sessionId,
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; kind?: string };
expect(body.ok).toBe(false);
expect(body.kind).toBe("transport_error");
});
it("returns 400 when the session has no OAuth auth provider (connected without authState)", async () => {
const { command, args } = getTestMcpServerCommand();
const res = await connect(h, { type: "stdio", command, args });
expect(res.status).toBe(200);
const { sessionId } = (await res.json()) as { sessionId: string };
const authRes = await postAuthState({
sessionId,
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
});
expect(authRes.status).toBe(400);
expect((await authRes.json()).error).toMatch(
/Session has no OAuth auth provider/,
);
await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
});
it("returns ok:true and hot-swaps the token when the session has an OAuth auth provider", async () => {
const { command, args } = getTestMcpServerCommand();
const res = await connect(
h,
{ type: "stdio", command, args },
{ oauthTokens: { access_token: "initial", token_type: "Bearer" } },
);
expect(res.status).toBe(200);
const { sessionId } = (await res.json()) as { sessionId: string };
const authRes = await postAuthState({
sessionId,
authState: {
oauthTokens: { access_token: "rotated", token_type: "Bearer" },
},
});
expect(authRes.status).toBe(200);
expect((await authRes.json()).ok).toBe(true);
await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
});
});
});

View file

@ -182,6 +182,29 @@ describe("RemoteSession", () => {
await expect(wait).resolves.toBeUndefined();
});
it("endSend is a no-op when no send is active", () => {
const session = new RemoteSession("s-endsend-noop");
expect(session.hasActiveSend()).toBe(false);
// No matching beginSend() — activeSendCount is already 0.
session.endSend();
expect(session.hasActiveSend()).toBe(false);
});
it("waitForRequestResponse with timeoutMs=0 never schedules a timer and still resolves", async () => {
const session = new RemoteSession("s-no-timeout");
const wait = session.waitForRequestResponse(7, 0);
session.onMessage({ jsonrpc: "2.0", id: 7, result: {} });
await expect(wait).resolves.toBeUndefined();
});
it("cancelRequestWait rejects a timeoutMs=0 wait without a timer to clear", async () => {
const session = new RemoteSession("s-no-timeout-cancel");
const wait = session.waitForRequestResponse(8, 0);
const rejection = expect(wait).rejects.toThrow(/cancelled/);
session.cancelRequestWait(8);
await rejection;
});
it("handleTransportAuthError rejects active request waits during send", async () => {
const session = new RemoteSession("s-auth");
session.beginSend();
@ -279,6 +302,28 @@ describe("RemoteSession", () => {
vi.useRealTimers();
});
it("setAuthState throws when no auth provider handle is set", () => {
const session = new RemoteSession("no-auth-provider");
expect(() =>
session.setAuthState({
oauthTokens: { access_token: "x", token_type: "Bearer" },
}),
).toThrow(/Session has no OAuth auth provider/);
});
it("cancelRequestWait is a no-op when the requestId has no pending wait", () => {
const session = new RemoteSession("s-cancel-noop");
// No wait was ever registered for this id — should not throw.
expect(() => session.cancelRequestWait(123)).not.toThrow();
});
it("handleTransportAuthError returns false for a non-AuthChallengeError", () => {
const session = new RemoteSession("s-not-auth-error");
expect(session.handleTransportAuthError(new Error("plain error"))).toBe(
false,
);
});
it("setAuthState updates the session auth provider", async () => {
const { createRemoteAuthProvider } =
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");

View file

@ -16,6 +16,11 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
} from "@inspector/core/auth/challenge.js";
const CONFIG: MCPServerConfig = {
type: "sse",
@ -69,6 +74,10 @@ interface MockFetchPlan {
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
authState?: (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
}
/**
@ -87,6 +96,11 @@ function mockFetch(plan: MockFetchPlan): typeof fetch {
if (url.includes("/api/mcp/events")) {
return plan.events ? plan.events(input, init) : sseResponse([]);
}
if (url.includes("/api/mcp/auth-state")) {
return plan.authState
? plan.authState(input, init)
: jsonResponse({ ok: true });
}
if (url.includes("/api/mcp/send")) {
return plan.send ? plan.send(input, init) : jsonResponse({ ok: true });
}
@ -101,6 +115,28 @@ function mockFetch(plan: MockFetchPlan): typeof fetch {
return fn as unknown as typeof fetch;
}
/** Push arbitrary RemoteEvent frames onto a controllable SSE stream. */
function createPushableEventStream() {
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
c.enqueue(encoder.encode(": keepalive\n\n"));
},
});
const push = (event: RemoteEvent) => {
controller?.enqueue(encoder.encode(sseFrame(event)));
};
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
push,
};
}
function makeTransport(
plan: MockFetchPlan,
extra: Partial<RemoteTransportOptions> = {},
@ -119,6 +155,19 @@ function makeTransport(
/** Wait a tick so the detached consumeEventStream loop can advance. */
const tick = () => new Promise<void>((r) => setTimeout(r, 10));
/**
* An SSE response whose stream stays open (never closes on its own) until the
* transport cancels it. Use for tests that need the transport to remain
* started/open past the initial start() call — the plan's default `events()`
* handler returns an already-closed empty stream, which auto-closes the
* transport shortly after start() resolves.
*/
function openEventsResponse(): Response {
return new Response(new ReadableStream<Uint8Array>({ start() {} }), {
status: 200,
});
}
describe("RemoteClientTransport (focused branch coverage)", () => {
describe("start()", () => {
it("throws Remote connect failed with status on non-OK connect", async () => {
@ -664,4 +713,638 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined();
});
});
describe("start() connect-time auth challenge and error branches", () => {
it("accepts the ok:true connect response shape (ok-wrapped sessionId)", async () => {
const t = makeTransport({
connect: () => jsonResponse({ ok: true, sessionId: "sess-ok" }),
});
await t.start();
expect(t.getRemoteBackendSessionId()).toBe("sess-ok");
await t.close();
});
it("throws a formatted error when connect returns a transport_error", async () => {
const t = makeTransport({
connect: () =>
jsonResponse({
ok: false,
kind: "transport_error",
error: "upstream unreachable",
}),
});
await expect(t.start()).rejects.toThrow(
/Remote connect failed: upstream unreachable/,
);
});
it("throws AuthChallengeError immediately when connect returns an auth_challenge and no authRecovery is configured", async () => {
const t = makeTransport({
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
});
try {
await t.start();
throw new Error("expected start() to throw");
} catch (e) {
expect(e).toBeInstanceOf(AuthChallengeError);
expect((e as AuthChallengeError).status).toBe(401);
}
});
it("preserves the raw httpStatus on the AuthChallengeError when no authRecovery is configured", async () => {
const t = makeTransport({
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: {
reason: "invalid_token",
raw: { httpStatus: 403 },
},
}),
});
try {
await t.start();
throw new Error("expected start() to throw");
} catch (e) {
expect((e as AuthChallengeError).status).toBe(403);
}
});
it("throws AuthRecoveryRequiredError with the EMA pending URL for a step_up_confirm outcome", async () => {
const t = makeTransport(
{
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "insufficient_scope" },
}),
},
{
authRecovery: {
handleAuthChallenge: vi.fn().mockResolvedValue({
kind: "step_up_confirm",
challenge: { reason: "insufficient_scope" },
}),
},
},
);
try {
await t.start();
throw new Error("expected start() to throw");
} catch (e) {
expect(e).toBeInstanceOf(AuthRecoveryRequiredError);
const err = e as AuthRecoveryRequiredError;
expect(err.authorizationUrl).toBe(EMA_STEP_UP_PENDING_URL);
expect(err.emaStepUpConfirm).toBe(true);
}
});
it("throws AuthRecoveryRequiredError with the authorization URL for an interactive outcome", async () => {
const authorizationUrl = new URL("https://idp.example/authorize");
const t = makeTransport(
{
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "unauthorized" },
}),
},
{
authRecovery: {
handleAuthChallenge: vi.fn().mockResolvedValue({
kind: "interactive",
authorizationUrl,
challenge: { reason: "unauthorized" },
}),
},
},
);
try {
await t.start();
throw new Error("expected start() to throw");
} catch (e) {
const err = e as AuthRecoveryRequiredError;
expect(err.authorizationUrl).toBe(authorizationUrl);
expect(err.emaStepUpConfirm).toBeUndefined();
}
});
it("rethrows the recovery error for a failed outcome", async () => {
const recoveryError = new Error("recovery failed");
const t = makeTransport(
{
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "unauthorized" },
}),
},
{
authRecovery: {
handleAuthChallenge: vi
.fn()
.mockResolvedValue({ kind: "failed", error: recoveryError }),
},
},
);
await expect(t.start()).rejects.toBe(recoveryError);
});
it("succeeds after a satisfied outcome retries connect and receives a session", async () => {
let connectAttempt = 0;
const t = makeTransport(
{
connect: () => {
connectAttempt += 1;
if (connectAttempt === 1) {
return jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
});
}
return jsonResponse({ sessionId: "recovered-session" });
},
events: () => openEventsResponse(),
},
{
authRecovery: {
handleAuthChallenge: vi
.fn()
.mockResolvedValue({ kind: "satisfied" }),
},
},
);
await t.start();
expect(connectAttempt).toBe(2);
expect(t.getRemoteBackendSessionId()).toBe("recovered-session");
await t.close();
});
it("throws AuthChallengeError when a retried connect still returns an auth_challenge", async () => {
const t = makeTransport(
{
connect: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
},
{
authRecovery: {
handleAuthChallenge: vi
.fn()
.mockResolvedValue({ kind: "satisfied" }),
},
},
);
await expect(t.start()).rejects.toBeInstanceOf(AuthChallengeError);
});
});
describe("attachToSession while closed", () => {
it("un-closes a previously closed transport before reattaching", async () => {
const events = vi.fn(() => openEventsResponse());
const t = makeTransport({ events });
await t.close(); // never started; closed=true, no sessionId so disconnect is skipped
expect((t as unknown as { closed: boolean }).closed).toBe(true);
await t.attachToSession("resumed-session");
expect((t as unknown as { closed: boolean }).closed).toBe(false);
expect(t.getRemoteBackendSessionId()).toBe("resumed-session");
await t.close();
});
});
describe("fetchFn default", () => {
it("falls back to globalThis.fetch when no fetchFn option is configured", () => {
const t = new RemoteClientTransport(
{ baseUrl: "http://remote.test" },
CONFIG,
);
expect((t as unknown as { fetchFn: typeof fetch }).fetchFn).toBe(
globalThis.fetch,
);
});
});
describe("parseSSE trailing buffer", () => {
it("ignores a trailing buffer line that is neither an event: nor a data: field", async () => {
const onmessage = vi.fn();
const t = makeTransport({
events: () => sseResponse([": trailing comment with no newline"]),
});
t.onmessage = onmessage;
await t.start();
await tick();
expect(onmessage).not.toHaveBeenCalled();
});
});
describe("consumeEventStream stops after transport_error closes it", () => {
it("does not process a message frame that follows a transport_error frame in the same batch", async () => {
const m: JSONRPCMessage = { jsonrpc: "2.0", id: 99, result: {} };
const onmessage = vi.fn();
const onerror = vi.fn();
const onclose = vi.fn();
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({ type: "transport_error", data: { error: "died" } }),
sseFrame({ type: "message", data: m }),
]),
});
t.onmessage = onmessage;
t.onerror = onerror;
t.onclose = onclose;
await t.start();
await tick();
expect(onerror).toHaveBeenCalledTimes(1);
expect(onclose).toHaveBeenCalledTimes(1);
expect(onmessage).not.toHaveBeenCalled();
});
});
describe("consumeEventStream restart / re-entrancy suppression", () => {
it("suppresses transport_error handling entirely while a reconnect is in flight", async () => {
const sse = createPushableEventStream();
const onerror = vi.fn();
const onclose = vi.fn();
const t = makeTransport({ events: () => sse.response });
t.onerror = onerror;
t.onclose = onclose;
await t.start();
await tick();
// Simulate a reconnect (attachToSession/close) already in flight on this
// transport when a stale transport_error frame from the old stream lands.
(
t as unknown as { restartingEventStream: boolean }
).restartingEventStream = true;
sse.push({ type: "transport_error", data: { error: "stale" } });
await tick();
expect(onerror).not.toHaveBeenCalled();
expect(onclose).not.toHaveBeenCalled();
(
t as unknown as { restartingEventStream: boolean }
).restartingEventStream = false;
await t.close();
});
it("does not double-fire onclose when the onerror handler synchronously closes the transport", async () => {
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({ type: "transport_error", data: { error: "boom" } }),
]),
});
const onclose = vi.fn();
let closePromise: Promise<void> | undefined;
t.onerror = () => {
closePromise = t.close();
};
t.onclose = onclose;
await t.start();
await tick();
await closePromise;
expect(onclose).toHaveBeenCalledTimes(1);
});
});
describe("settleSseResponseWait guard", () => {
it("ignores a message-type SSE event with no id (notification)", async () => {
const onmessage = vi.fn();
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({
type: "message",
data: {
jsonrpc: "2.0",
method: "notifications/progress",
params: {},
},
}),
]),
});
t.onmessage = onmessage;
await t.start();
await tick();
expect(onmessage).toHaveBeenCalledTimes(1);
});
it("ignores a message-type SSE event that has an id but neither result nor error", async () => {
const onmessage = vi.fn();
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({
type: "message",
data: { jsonrpc: "2.0", id: 3, method: "sampling/createMessage" },
}),
]),
});
t.onmessage = onmessage;
await t.start();
await tick();
expect(onmessage).toHaveBeenCalledTimes(1);
});
});
describe("cancelSseResponseWait no-op when already settled", () => {
it("no-ops when the SSE response for the id already settled before the auth_challenge HTTP reply arrives", async () => {
const sse = createPushableEventStream();
const t = makeTransport({
events: () => sse.response,
send: async (_input, init) => {
const body = JSON.parse(String(init?.body)) as {
message: { id?: string | number };
};
// Simulate the SSE response for this id landing first...
sse.push({
type: "message",
data: { jsonrpc: "2.0", id: body.message.id, result: {} },
});
// ...and let the independent SSE consumer settle it before the
// HTTP reply (an auth_challenge for the same id) comes back.
await tick();
return jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
});
},
});
await t.start();
await expect(
t.send({ jsonrpc: "2.0", id: 11, method: "tools/list" }),
).rejects.toBeInstanceOf(AuthChallengeError);
await t.close();
});
});
describe("postSend catch skips cancel for requestId-less messages", () => {
it("does not attempt to cancel an SSE wait for a notification when requestSend throws", async () => {
const notif: JSONRPCMessage = {
jsonrpc: "2.0",
method: "notifications/cancelled",
};
const t = makeTransport({
events: () => openEventsResponse(),
send: () => {
throw new Error("network down");
},
});
await t.start();
await expect(t.send(notif)).rejects.toThrow(/network down/);
await t.close();
});
});
describe("send() generic transport_error and notification failure paths", () => {
it("throws the raw error message when send returns a non-auth_challenge failure kind", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
send: () =>
jsonResponse({
ok: false,
kind: "transport_error",
error: "upstream 503",
}),
});
await t.start();
await expect(
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
).rejects.toThrow(/upstream 503/);
await t.close();
});
it("skips cancelSseResponseWait for a notification whose send fails", async () => {
const notif: JSONRPCMessage = {
jsonrpc: "2.0",
method: "notifications/cancelled",
};
const t = makeTransport({
events: () => openEventsResponse(),
send: () =>
jsonResponse({ ok: false, kind: "transport_error", error: "boom" }),
});
await t.start();
await expect(t.send(notif)).rejects.toThrow(/boom/);
await t.close();
});
});
describe("send() auth_challenge without recovery configured", () => {
it("throws immediately when no authRecovery is configured", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
send: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
});
await t.start();
await expect(
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
).rejects.toBeInstanceOf(AuthChallengeError);
await t.close();
});
it("throws immediately with the raw httpStatus when authRecovery has no pushAuthState handler", async () => {
const t = makeTransport(
{
events: () => openEventsResponse(),
send: () =>
jsonResponse({
ok: false,
kind: "auth_challenge",
authChallenge: {
reason: "token_expired",
raw: { httpStatus: 403 },
},
}),
},
{ authRecovery: { handleAuthChallenge: vi.fn() } },
);
await t.start();
try {
await t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
throw new Error("expected send() to throw");
} catch (e) {
expect((e as AuthChallengeError).status).toBe(403);
}
await t.close();
});
});
describe("pushAuthState guards", () => {
it("throws Transport not started when called before start()", async () => {
const t = makeTransport({});
await expect(
t.pushAuthState({
oauthTokens: { access_token: "a", token_type: "Bearer" },
}),
).rejects.toThrow(/Transport not started/);
});
it("throws Transport is closed after close()", async () => {
const t = makeTransport({});
await t.start();
await t.close();
await expect(
t.pushAuthState({
oauthTokens: { access_token: "a", token_type: "Bearer" },
}),
).rejects.toThrow(/Transport is closed/);
});
it("throws when the resolved auth state has neither oauthTokens nor oauthClient", async () => {
const t = makeTransport({ events: () => openEventsResponse() });
await t.start();
await expect(t.pushAuthState({})).rejects.toThrow(
/No auth state to push/,
);
await t.close();
});
it("throws No auth provider configured when called with no explicit state and no authProvider", async () => {
const t = makeTransport({ events: () => openEventsResponse() });
await t.start();
await expect(t.pushAuthState()).rejects.toThrow(
/No auth provider configured/,
);
await t.close();
});
it("throws No OAuth tokens available when the configured authProvider has no tokens", async () => {
const authProvider = {
tokens: async () => undefined,
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
const t = makeTransport(
{ events: () => openEventsResponse() },
{ authProvider },
);
await t.start();
await expect(t.pushAuthState()).rejects.toThrow(
/No OAuth tokens available/,
);
await t.close();
});
it("throws a formatted error when the auth-state POST responds non-OK", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
authState: () => new Response("nope", { status: 500 }),
});
await t.start();
await expect(
t.pushAuthState({
oauthTokens: { access_token: "a", token_type: "Bearer" },
}),
).rejects.toThrow(/Remote auth-state update failed \(500\)/);
await t.close();
});
it("throws the server-provided error message when the auth-state POST reports ok:false", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
authState: () => jsonResponse({ ok: false, error: "session gone" }),
});
await t.start();
await expect(
t.pushAuthState({
oauthTokens: { access_token: "a", token_type: "Bearer" },
}),
).rejects.toThrow(/session gone/);
await t.close();
});
it("throws a default error message when the auth-state POST reports ok:false with no error field", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
authState: () => jsonResponse({ ok: false }),
});
await t.start();
await expect(
t.pushAuthState({
oauthTokens: { access_token: "a", token_type: "Bearer" },
}),
).rejects.toThrow(/Remote auth-state update failed/);
await t.close();
});
it("accepts an explicit authState argument without consulting authProvider", async () => {
const authProvider = {
tokens: vi.fn(),
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
const authStateCalls: unknown[] = [];
const t = makeTransport(
{
events: () => openEventsResponse(),
authState: (_input, init) => {
authStateCalls.push(JSON.parse(String(init?.body)));
return jsonResponse({ ok: true });
},
},
{ authProvider },
);
await t.start();
await t.pushAuthState({
oauthTokens: { access_token: "explicit", token_type: "Bearer" },
});
expect(authStateCalls).toHaveLength(1);
await t.close();
});
});
describe("misc coverage", () => {
it("setOnAuthChallenge updates the ambient auth challenge handler", async () => {
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({
type: "auth_challenge",
data: { reason: "token_expired" },
}),
]),
});
const onAuthChallenge = vi.fn();
t.setOnAuthChallenge(onAuthChallenge);
await t.start();
await tick();
expect(onAuthChallenge).toHaveBeenCalledWith({ reason: "token_expired" });
});
it("is a no-op when start() is called again while already connected", async () => {
const connect = vi.fn(() => jsonResponse({ sessionId: "s" }));
const t = makeTransport({ connect, events: () => openEventsResponse() });
await t.start();
await t.start();
expect(connect).toHaveBeenCalledTimes(1);
await t.close();
});
it("rejects a pending SSE response wait when close() runs mid-send", async () => {
const t = makeTransport({
events: () => openEventsResponse(),
send: () => jsonResponse({ ok: true }),
});
await t.start();
const sendPromise = t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
const rejection = expect(sendPromise).rejects.toThrow(/Transport closed/);
await t.close();
await rejection;
});
});
});

View file

@ -788,4 +788,113 @@ describe("server.ts supplemental coverage", () => {
}
});
});
describe("keychain-unavailable 503 on PUT/DELETE", () => {
class UnavailableOnWriteStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
}
it("PUT returns 503 when the keychain write is unavailable", async () => {
const h = await start({
secretStore: new UnavailableOnWriteStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "new-secret",
},
}),
});
expect(res.status).toBe(503);
expect((await res.json()).error).toMatch(/libsecret missing/);
} finally {
await stop(h);
}
});
it("DELETE returns 503 when the keychain sweep is unavailable", async () => {
const h = await start({
secretStore: new UnavailableOnWriteStore(),
seedConfig: JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "DELETE",
});
expect(res.status).toBe(503);
expect((await res.json()).error).toMatch(/libsecret missing/);
} finally {
await stop(h);
}
});
});
describe("migratePlaintextSecrets rethrows a non-keychain error", () => {
class ThrowingOnGetStore implements SecretStore {
async get(): Promise<string | null> {
throw new Error("disk full");
}
async set(): Promise<void> {
/* unreachable in this test */
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
/* no-op */
}
}
it("GET /api/servers returns 500 when migration hits a non-KeychainUnavailableError", async () => {
const h = await start({
secretStore: new ThrowingOnGetStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to read server list/);
} finally {
await stop(h);
}
});
});
});