diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index 7adb52c4..62698e81 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -239,6 +239,48 @@ describe("MCP authentication", () => { expect(warn).not.toHaveBeenCalled() }) + it("surfaces a 200 session body that fails the session contract as TransientAuthError", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) + + await expect( + validateApiKey("sm_badbody_key_0123456789abcd", API_URL), + ).rejects.toThrow(TransientAuthError) + expect(error).toHaveBeenCalledTimes(1) + expect(error.mock.calls[0]?.[0]).toBe( + "Auth backend unavailable during API key validation: Invalid session response", + ) + expect(warn).not.toHaveBeenCalled() + }) + + it("surfaces a non-JSON 200 session body as TransientAuthError", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response("upstream error", { status: 200 }), + ), + ) + + await expect( + validateApiKey("sm_htmlbody_key_0123456789abc", API_URL), + ).rejects.toThrow(TransientAuthError) + expect(error).toHaveBeenCalledTimes(1) + expect(warn).not.toHaveBeenCalled() + }) + it("surfaces a session-endpoint timeout as TransientAuthError", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) const error = vi.spyOn(console, "error").mockImplementation(() => {}) diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index b1e56bf9..a68d7c9f 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -44,9 +44,13 @@ export async function fetchSession( ) } - const result = sessionInfoSchema.safeParse(await response.json()) + const body = await response.json().catch((error: unknown) => { + if (error instanceof SyntaxError) return undefined + throw error + }) + const result = sessionInfoSchema.safeParse(body) if (!result.success) { - throw new Error("Invalid session response") + throw new TransientAuthError("Invalid session response") } return result.data @@ -86,6 +90,7 @@ const TRANSIENT_ERROR_NAMES = new Set([ const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"]) function transientAuthErrorFor(error: unknown): TransientAuthError | null { + if (error instanceof TransientAuthError) return error const status = (error as { status?: unknown } | null)?.status if (typeof status === "number" && status !== 401 && status !== 403) { return new TransientAuthError(`Session endpoint returned ${status}`, status)