feat(mcp): validate API keys via /session (#1839)

## What

The Supermemory API now serves the session endpoint unversioned at `/session` (supermemoryai/mono#3626). `sm_` API-key validation in the MCP worker calls `/session` instead of `/v3/session`. One-line code change.

- `apps/mcp/src/server/auth/index.ts`: `fetchSession` requests `/session`.
- `apps/mcp/src/server/auth/index.test.ts`: asserts the new URL and that a refused key makes exactly one request.
- `apps/mcp/src/shared/types.ts`: comment updated.

## Deploy order

This worker depends on the API serving `/session`. **Deploy mono#3626 first.** Until then, production answers `/session` with 404 for full keys and 403 for scoped keys (the scope allowlist is checked before routing), and every `sm_` key would be rejected.

No fallback to `/v3/session` on purpose: the only "old server" in scope is that deploy window, and a failed-request fallback would blur 403/404 semantics for a case merge ordering already covers.

## Verification

- `bun run check-types`, `vitest run src` (29/29).
- Against the API with mono#3626 running: full key → `/session` 200 valid; scoped key → `/session` 200 valid; bad key → `/session` 401 rejected, single call.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
sreedharsreeram 2026-10-24 23:59:06 +00:00
parent f3001517d1
commit f355dc8039
3 changed files with 9 additions and 7 deletions

View file

@ -177,7 +177,7 @@ describe("MCP authentication", () => {
scopes: [],
})
expect(fetchSpy).toHaveBeenCalledWith(
`${API_URL}/v3/session`,
`${API_URL}/session`,
expect.objectContaining({
headers: { Authorization: `Bearer ${key}` },
}),
@ -197,14 +197,16 @@ describe("MCP authentication", () => {
it("rejects an API key the session endpoint refuses", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const error = vi.spyOn(console, "error").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
)
const fetchSpy = vi
.fn()
.mockResolvedValue(new Response(null, { status: 401 }))
vi.stubGlobal("fetch", fetchSpy)
await expect(
validateApiKey("sm_revoked_key_0123456789abcdef", API_URL),
).resolves.toBeNull()
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy.mock.calls[0]?.[0]).toBe(`${API_URL}/session`)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
"API key rejected: Session request failed with status 401",

View file

@ -31,7 +31,7 @@ export async function fetchSession(
bearerToken: string,
apiUrl: string,
): Promise<SessionInfo> {
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/v3/session`, {
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/session`, {
method: "GET",
headers: { Authorization: `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),

View file

@ -11,7 +11,7 @@ export const namespaceAccessSchema = z.object({
export type NamespaceAccess = z.infer<typeof namespaceAccessSchema>
// /v3/session has no v5 replacement and still sends container-tag field names.
// /session still sends container-tag field names.
const sessionNamespaceAccessSchema = z
.object({ containerTag: z.string(), permission: z.enum(["read", "write"]) })
.transform(({ containerTag, permission }) => ({