From 152a4e611dd129c95b5f555d52e7bb38a9836aa3 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:32:59 +0000 Subject: [PATCH 1/2] fix(mcp): treat an unparseable session response as a transient auth failure Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- apps/mcp/src/server/auth/index.test.ts | 42 ++++++++++++++++++++++++++ apps/mcp/src/server/auth/index.ts | 9 ++++-- 2 files changed, 49 insertions(+), 2 deletions(-) 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) From 7fc3dc2d7d687a45aab059c395c18bc1551772af Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:20:32 +0000 Subject: [PATCH 2/2] fix(mcp): accept null org and role in the session response Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- apps/mcp/src/server/auth/index.test.ts | 70 +++++++++++--------------- apps/mcp/src/server/auth/index.ts | 14 +++--- apps/mcp/src/shared/types.ts | 4 +- 3 files changed, 36 insertions(+), 52 deletions(-) diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index 62698e81..26f4a449 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -212,6 +212,34 @@ describe("MCP authentication", () => { expect(error).not.toHaveBeenCalled() }) + it("rejects an API key whose session has no organization", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + user: { id: "user_test", email: "test@example.com", name: "Test" }, + org: null, + session: null, + role: null, + accessType: "full", + containerTags: null, + scope: { type: "full", permission: "write" }, + }), + ), + ) + + await expect( + validateApiKey("sm_noorg_key_0123456789abcdef", API_URL), + ).resolves.toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + "API key rejected: session has no organization", + ) + expect(error).not.toHaveBeenCalled() + }) + it("rejects malformed API keys without an API request", async () => { const fetchSpy = vi.fn() vi.stubGlobal("fetch", fetchSpy) @@ -239,48 +267,6 @@ 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 a68d7c9f..819e1f31 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -44,13 +44,9 @@ export async function fetchSession( ) } - const body = await response.json().catch((error: unknown) => { - if (error instanceof SyntaxError) return undefined - throw error - }) - const result = sessionInfoSchema.safeParse(body) + const result = sessionInfoSchema.safeParse(await response.json()) if (!result.success) { - throw new TransientAuthError("Invalid session response") + throw new Error("Invalid session response") } return result.data @@ -90,7 +86,6 @@ 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) @@ -122,7 +117,10 @@ export async function validateApiKey( try { const session = await fetchSession(token, apiUrl) const organizationId = session.org?.id - if (!organizationId) return null + if (!organizationId) { + console.warn("API key rejected: session has no organization") + return null + } const user: AuthUser = { userId: session.user.id, diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index ad59a02b..67914630 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -37,8 +37,8 @@ export const sessionInfoSchema = z email: z.string().optional(), name: z.string().optional(), }), - org: z.looseObject({ id: z.string().min(1) }).optional(), - role: z.string().optional(), + org: z.looseObject({ id: z.string().min(1) }).nullish(), + role: z.string().nullish(), accessType: z.enum(["full", "restricted"]).optional(), containerTags: z.array(sessionNamespaceAccessSchema).nullable().optional(), scope: sessionScopeSchema.optional(),