From 2ecc142fd9201d2847bceaa0387b8fe963e5f8a6 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:11:25 +0100 Subject: [PATCH 1/8] fix(mcp): emit OAuth challenge on unauthenticated GET/DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/mcp returned 405 with only Allow: POST and no WWW-Authenticate header. That short-circuited Claude.ai's connector discovery probe — the initial unauthenticated GET is exactly how a strict OAuth resource server is supposed to advertise the protected-resource metadata URL, and our 405 left no breadcrumb for the client to follow. Result: "Couldn't reach the MCP server" from the connector form, even though POST was correctly emitting the challenge. Move the auth-check out front. On unauthenticated requests (any method), return 401 with the existing WWW-Authenticate: Bearer resource_metadata=... challenge. On authenticated GET/DELETE, keep the 405 — the leak-fix reasoning (per-request McpServer instances pinned by long-lived SSE streams in stateless mode) only kicks in once the request is past auth. Verified against arin.usedocsyde.com: GET /api/mcp → was 405 Allow:POST, becomes 401 + WWW-Authenticate POST /api/mcp (no auth) → unchanged: 401 + WWW-Authenticate POST /api/mcp (valid bearer) → unchanged: tools/list etc. GET /api/mcp (valid bearer) → unchanged: 405 Allow:POST Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/api/mcp/route.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index f95f1da..07a4345 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -32,8 +32,15 @@ export async function POST(request: Request): Promise { } } -// Stateless server doesn't push notifications and has no sessions to terminate, -// so refuse the GET SSE stream and DELETE — both would only allocate without value -// and GET in particular leaks McpServer instances by holding them open indefinitely. -export const GET = methodNotAllowed; -export const DELETE = methodNotAllowed; +// Unauthenticated GET/DELETE need to emit the OAuth challenge so connector +// discovery can reach /.well-known/oauth-protected-resource. Authenticated +// GET/DELETE still return 405 — we don't push notifications and don't track +// sessions, so the SSE stream would only pin McpServer instances in memory. +async function challengeOrNotAllowed(request: Request): Promise { + const ctx = await authenticate(request); + if (!ctx) return unauthorizedResponse(); + return methodNotAllowed(); +} + +export const GET = challengeOrNotAllowed; +export const DELETE = challengeOrNotAllowed; From af333215cb4ba998f74e94c1b0f35bf3f6f36355 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:12:57 +0100 Subject: [PATCH 2/8] fix(oauth): advertise the path-aware AS in PRM Better Auth mounts under /api/auth and self-identifies its OAuth issuer as ${APP_URL}/api/auth in the AS metadata document, but our protected- resource metadata advertised the AS as the bare root ${APP_URL}. Strict clients (which the Claude.ai connector turns out to be) walk RFC 8414's metadata-URL construction rules from the advertised issuer and validate that the discovered document's issuer claim matches the one they used to discover it. With our previous PRM, the discovered issuer (.../api/auth) didn't match the advertised one (root), so a spec-compliant client would either bail or get into a confused state. Align PRM with what the AS actually reports. Path-aware metadata route lands in the next commit so RFC 8414 discovery from this issuer string resolves cleanly. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/.well-known/oauth-protected-resource/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts index d42d480..905ba6c 100644 --- a/app/.well-known/oauth-protected-resource/route.ts +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -3,7 +3,7 @@ import { env } from "@/lib/env"; export function GET() { return Response.json({ resource: `${env.APP_URL}/api/mcp`, - authorization_servers: [env.APP_URL], + authorization_servers: [`${env.APP_URL}/api/auth`], bearer_methods_supported: ["header"], scopes_supported: ["mcp"], }); From 5f7a2a88403ab238e85e6179ddb9ee23a1dd0bc8 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:16:15 +0100 Subject: [PATCH 3/8] feat(oauth): serve AS metadata at the path-aware /.well-known/.../api/auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8414 says when the issuer URL has a path component, the metadata discovery URL is constructed by inserting "/.well-known/oauth-authorization-server" between the host and the path. Our issuer is ${APP_URL}/api/auth, so the canonical metadata location is ${APP_URL}/.well-known/oauth-authorization-server/api/auth — and now the PRM's authorization_servers entry points clients there. The bare-root route stays in place for back-compat. Same handler at both mount points; oauthProviderAuthServerMetadata(auth) just delegates to auth.api.getOAuthServerConfig and doesn't care where it's exposed. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- .../oauth-authorization-server/api/auth/route.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/.well-known/oauth-authorization-server/api/auth/route.ts diff --git a/app/.well-known/oauth-authorization-server/api/auth/route.ts b/app/.well-known/oauth-authorization-server/api/auth/route.ts new file mode 100644 index 0000000..c13e802 --- /dev/null +++ b/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -0,0 +1,7 @@ +import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; +import { auth } from "@/lib/auth"; + +// Path-aware AS metadata location per RFC 8414. The bare-root variant in +// app/.well-known/oauth-authorization-server/route.ts stays for back-compat +// with anything that already cached it. +export const GET = oauthProviderAuthServerMetadata(auth); From 34ee12429fc570662a9dd48533b4d80f5f3cf3b6 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:18:03 +0100 Subject: [PATCH 4/8] chore(auth): drop silenceWarnings.oauthAuthServerConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin warning was telling us to expose the AS metadata at the path-aware location (.../api/auth) and we silenced it instead. The previous commit actually adds that route, so the warning is now satisfied at the source — drop the silencer. openidConfig stays silenced; OIDC discovery isn't the focus of this fix and most OAuth-only clients (including Claude.ai) don't reach for it. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index 95c4b1a..501829e 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -32,7 +32,6 @@ export const auth = betterAuth({ return orgId ? { org_id: orgId } : {}; }, silenceWarnings: { - oauthAuthServerConfig: true, openidConfig: true, }, }), From cb936c18eb8266719f32d8eb2c1c8bb9a5da4659 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:22:53 +0100 Subject: [PATCH 5/8] fix(mcp): verify JWT issuer against \${APP_URL}/api/auth Better Auth issues JWTs with iss = baseURL + basePath = ${APP_URL}/api/auth (see its jwt plugin's signing path: defaultIss = options?.jwt?.issuer ?? baseURLOrigin, and the OAuth provider uses Better Auth's baseURL- derived issuer). We were verifying against the bare ${APP_URL}, so even once Claude.ai got past discovery and obtained an audience-bound JWT the very next /api/mcp POST would have been rejected by jose's iss check and the user would have seen a token-loop. Export a MCP_ISSUER constant so tests can reuse it. The existing "wrong-issuer" test still uses an explicit bogus value and continues to assert rejection. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/mcp/auth.ts | 3 ++- tests/mcp-oauth.test.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/mcp/auth.ts b/lib/mcp/auth.ts index eb61cdf..896a839 100644 --- a/lib/mcp/auth.ts +++ b/lib/mcp/auth.ts @@ -8,6 +8,7 @@ import { resolveServiceToken } from "@/lib/service-tokens"; const defaultJwks = createRemoteJWKSet(new URL(`${env.APP_URL}/api/auth/jwks`)); export const MCP_RESOURCE = `${env.APP_URL}/api/mcp`; +export const MCP_ISSUER = `${env.APP_URL}/api/auth`; export type McpAuthContext = { organizationId: string; actor: Actor }; export type AuthenticateOptions = { jwks?: JWTVerifyGetKey }; @@ -50,7 +51,7 @@ export async function authenticate( try { const { payload } = await jwtVerify(token, opts.jwks ?? defaultJwks, { audience: MCP_RESOURCE, - issuer: env.APP_URL, + issuer: MCP_ISSUER, }); const orgId = payload.org_id; if (typeof orgId !== "string" || orgId.length === 0) return null; diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index 0a641d3..4ea29b3 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createDb } from "@/db/client"; import { organization } from "@/db/schema/auth"; -import { authenticate, MCP_RESOURCE, unauthorizedResponse } from "@/lib/mcp/auth"; +import { + authenticate, + MCP_ISSUER, + MCP_RESOURCE, + unauthorizedResponse, +} from "@/lib/mcp/auth"; import { issueServiceToken } from "@/lib/service-tokens"; import { resetDb } from "./setup"; @@ -23,7 +28,7 @@ async function mintJwt(opts: { opts.payload ?? { org_id: "org_jwt", sub: "user_1", client_id: "test_client" }, ) .setProtectedHeader({ alg: ALG, kid: opts.kid }) - .setIssuer(opts.issuer ?? APP_URL) + .setIssuer(opts.issuer ?? MCP_ISSUER) .setAudience(opts.audience ?? MCP_RESOURCE) .setIssuedAt() .setExpirationTime(exp) From 9c9eec9d5f2cf6791b39f17bbcf880cba8c3c215 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:30:47 +0100 Subject: [PATCH 6/8] test(mcp): cover GET/DELETE OAuth-challenge + authenticated 405 paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests that drive the route handler directly: - GET /api/mcp with no Authorization → 401 + WWW-Authenticate - DELETE /api/mcp with no Authorization → 401 + WWW-Authenticate - GET /api/mcp with a valid service token → 405 + Allow: POST The challenge tests would have caught the original regression (Claude.ai discovery probe getting a 405 with no resource_metadata pointer). The 405 test guards the leak-fix invariant: once a request is past auth, we still refuse GET so the stateless McpServer instance doesn't get pinned by a long-lived SSE stream. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- tests/mcp-oauth.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index 4ea29b3..8bb3273 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -162,3 +162,49 @@ describe("MCP dual-auth", () => { expect(await authenticate(bearer(token), { jwks })).toBeNull(); }); }); + +describe("MCP route methods", () => { + beforeEach(async () => { + await resetDb(); + }); + + function expectOAuthChallenge(res: Response): void { + expect(res.status).toBe(401); + const wa = res.headers.get("WWW-Authenticate") ?? ""; + expect(wa).toContain("Bearer"); + expect(wa).toContain( + `resource_metadata="${APP_URL}/.well-known/oauth-protected-resource"`, + ); + } + + test("GET /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { method: "GET" }), + ); + expectOAuthChallenge(res); + }); + + test("DELETE /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { DELETE } = await import("@/app/api/mcp/route"); + const res = await DELETE( + new Request("http://localhost/api/mcp", { method: "DELETE" }), + ); + expectOAuthChallenge(res); + }); + + test("GET /api/mcp with a valid service token returns 405", async () => { + const orgId = "org_route"; + await db.insert(organization).values({ id: orgId, name: "Route", slug: "route" }); + const issued = await issueServiceToken(db, orgId, "ci-bot"); + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { + method: "GET", + headers: { authorization: `Bearer ${issued.token}` }, + }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + }); +}); From e06596da6861d7ed93f4a2455408bb2b30818c51 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 21:44:42 +0100 Subject: [PATCH 7/8] style: tighten the two new multi-line comments to one-liners AGENTS.md forbids multi-line comment blocks. The explanations in app/api/mcp/route.ts and app/.well-known/oauth-authorization-server/api/auth/route.ts both ran 3-4 lines. Collapsed each to a single comment. Caught in self-review on PR #8. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/.well-known/oauth-authorization-server/api/auth/route.ts | 4 +--- app/api/mcp/route.ts | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/.well-known/oauth-authorization-server/api/auth/route.ts b/app/.well-known/oauth-authorization-server/api/auth/route.ts index c13e802..36b4e04 100644 --- a/app/.well-known/oauth-authorization-server/api/auth/route.ts +++ b/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -1,7 +1,5 @@ import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; import { auth } from "@/lib/auth"; -// Path-aware AS metadata location per RFC 8414. The bare-root variant in -// app/.well-known/oauth-authorization-server/route.ts stays for back-compat -// with anything that already cached it. +// RFC 8414 path-aware metadata location; bare-root variant stays for back-compat. export const GET = oauthProviderAuthServerMetadata(auth); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 07a4345..1c24166 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -32,10 +32,7 @@ export async function POST(request: Request): Promise { } } -// Unauthenticated GET/DELETE need to emit the OAuth challenge so connector -// discovery can reach /.well-known/oauth-protected-resource. Authenticated -// GET/DELETE still return 405 — we don't push notifications and don't track -// sessions, so the SSE stream would only pin McpServer instances in memory. +// Unauthed GET/DELETE emit the OAuth challenge for discovery; authed still 405 (leak-fix invariant). async function challengeOrNotAllowed(request: Request): Promise { const ctx = await authenticate(request); if (!ctx) return unauthorizedResponse(); From 1b2c98a5c3517b54c2459f5c77f285e7e99652f6 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Thu, 14 May 2026 22:26:54 +0100 Subject: [PATCH 8/8] fix(mcp): import canonical MCP audience from lib/auth, drop the dup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string \${env.APP_URL}/api/mcp was defined independently in two places: lib/auth.ts (the value passed to oauthProvider's validAudiences when minting tokens) and lib/mcp/auth.ts (the value passed to jose's audience param when verifying them). If the MCP endpoint path ever changes and only one site is updated, Better Auth issues tokens with one audience while the verifier rejects them against the stale one — silent failure with no diagnostic beyond [mcp] jwt verify failed. Import MCP_AUDIENCE from lib/auth in the verifier and alias the existing MCP_RESOURCE export to it so test imports stay valid without having to touch test files. No circular dep — lib/auth doesn't import lib/mcp/auth. Reported in enkii's review on PR #8. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/mcp/auth.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/mcp/auth.ts b/lib/mcp/auth.ts index 896a839..cdd36a5 100644 --- a/lib/mcp/auth.ts +++ b/lib/mcp/auth.ts @@ -2,12 +2,13 @@ import { eq } from "drizzle-orm"; import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; import { db } from "@/db/client"; import { user } from "@/db/schema/auth"; +import { MCP_AUDIENCE } from "@/lib/auth"; import type { Actor } from "@/lib/audit"; import { env } from "@/lib/env"; import { resolveServiceToken } from "@/lib/service-tokens"; const defaultJwks = createRemoteJWKSet(new URL(`${env.APP_URL}/api/auth/jwks`)); -export const MCP_RESOURCE = `${env.APP_URL}/api/mcp`; +export const MCP_RESOURCE = MCP_AUDIENCE; export const MCP_ISSUER = `${env.APP_URL}/api/auth`; export type McpAuthContext = { organizationId: string; actor: Actor }; @@ -50,7 +51,7 @@ export async function authenticate( try { const { payload } = await jwtVerify(token, opts.jwks ?? defaultJwks, { - audience: MCP_RESOURCE, + audience: MCP_AUDIENCE, issuer: MCP_ISSUER, }); const orgId = payload.org_id;