Skip to content

fix(mcp): make OAuth discovery work for web connector clients#8

Merged
Timmyy3000 merged 8 commits into
mainfrom
fix/mcp-oauth-discovery
May 14, 2026
Merged

fix(mcp): make OAuth discovery work for web connector clients#8
Timmyy3000 merged 8 commits into
mainfrom
fix/mcp-oauth-discovery

Conversation

@Timmyy3000
Copy link
Copy Markdown
Owner

@Timmyy3000 Timmyy3000 commented May 14, 2026

Summary

Claude.ai's "Add custom connector" form failed against the live MCP endpoint with "Couldn't reach the MCP server." Live probes against the deployment surfaced three layered issues in the OAuth discovery + JWT verification path. All three are fixed; the route handler, the protected-resource metadata, the path-aware AS metadata, and the JWT verifier are now mutually consistent and discoverable from a single unauthenticated request.

What's Included

  • app/api/mcp/route.ts — unauthenticated GET/DELETE now return the same 401 + WWW-Authenticate: Bearer resource_metadata=… challenge as POST. Authenticated GET/DELETE still return 405 Method Not Allowed so the leak-fix invariant (no long-lived SSE streams pinning per-request McpServer instances) is preserved.
  • app/.well-known/oauth-protected-resource/route.tsauthorization_servers now advertises ${APP_URL}/api/auth, matching what Better Auth actually reports as its issuer in the AS metadata document.
  • app/.well-known/oauth-authorization-server/api/auth/route.ts — new path-aware route, the canonical metadata URL per RFC 8414's discovery rules when the issuer URL has a path component. Same handler as the bare-root route (oauthProviderAuthServerMetadata(auth)), which stays in place for back-compat.
  • lib/auth.ts — drop silenceWarnings.oauthAuthServerConfig; the plugin warning was telling us to add exactly the path-aware route above.
  • lib/mcp/auth.ts — verify JWT iss against ${APP_URL}/api/auth (matches what Better Auth's jwt() plugin produces via defaultIss = baseURLOrigin + basePath), and verify aud against the canonical MCP_AUDIENCE imported from lib/auth.ts (was a separate ${env.APP_URL}/api/mcp literal). Exports a new MCP_ISSUER constant for reuse.

Tests

  • bun run lint: passed
  • bun run typecheck: passed
  • bun test: passed — 112/112 (3 new route-level tests for the GET/DELETE challenge + authenticated 405 path; existing tests updated to mint JWTs with the new canonical issuer)
  • bun run build: passed

Test Plan

Curl reproductions against the deployment after this lands:

  • curl -i GET /api/mcp → expect 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", scope="mcp". Previously 405 Allow:POST with no challenge header.
  • curl -i POST /api/mcp → expect 401 with same WWW-Authenticate. Unchanged.
  • curl -i /.well-known/oauth-protected-resource → JSON; authorization_servers is now ["…/api/auth"].
  • curl -i /.well-known/oauth-authorization-server/api/auth → 200 JSON with issuer == "…/api/auth", matching what the metadata self-declares.
  • curl -i /.well-known/oauth-authorization-server → still 200 (back-compat).

End-to-end:

  • Open Claude.ai → Settings → Connectors → "Add custom connector" → URL ${APP_URL}/api/mcp. Walk DCR → sign-in → consent → tool call. Inspect a server log to confirm lib/mcp/auth.ts accepts the issued JWT (no [mcp] jwt verify failed warn) and the tool call is scoped to the consented org.

Local Review Findings

  • P0: none.
  • P1: none.
  • P2: fixed — (1) the two new route files had 3–4 line comment blocks; AGENTS.md forbids multi-line blocks; tightened to single-line comments. (2) ${env.APP_URL}/api/mcp was defined independently in lib/auth.ts (for issuing tokens) and lib/mcp/auth.ts (for verifying them) — a divergence between the two would silently fail every JWT with no diagnostic beyond [mcp] jwt verify failed. The verifier now imports MCP_AUDIENCE from lib/auth.ts as the single source of truth and aliases the existing MCP_RESOURCE export to it (no test-file changes needed).

Timmyy3000 and others added 6 commits May 14, 2026 21:11
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>
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>
…/auth

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>
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>
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>
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>
@github-actions
Copy link
Copy Markdown

enkii enkii  ·  code review

Working on this PR…

View job run

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>
@github-actions
Copy link
Copy Markdown

enkii enkii  ·  code review

Working on this PR…

View job run

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enkii enkii  ·  code review

Summary

This PR fixes three layered OAuth discovery bugs that prevented Claude.ai's custom connector form from discovering the MCP endpoint: (1) GET/DELETE on /api/mcp now return 401 with WWW-Authenticate challenge instead of a bare 405, (2) the protected-resource metadata now advertises the path-correct authorization server URL (/api/auth), and (3) the JWT verifier now expects the canonical issuer ${APP_URL}/api/auth matching what Better Auth produces. A new path-aware AS metadata route is added per RFC 8414, and the corresponding plugin warning suppression is removed. Tests cover all new behavior paths.

One P2 maintainability finding: the MCP_RESOURCE audience constant is independently defined in two modules with no shared source of truth, creating a silent breakage risk if the path ever changes. Otherwise the diff is clean, each fix is at the right layer, and the leak-fix invariant (authenticated GET/DELETE still return 405) is preserved.

Mergeability Score: 4/5

Likely mergeable after reviewing the flagged low-risk comments.

Comment thread lib/mcp/auth.ts Outdated
@@ -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`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated MCP resource constant creates hidden audience-mismatch risk

The string ${env.APP_URL}/api/mcp is defined independently as MCP_RESOURCE in both lib/mcp/auth.ts (line 10, used for JWT audience verification) and lib/auth.ts (line 11, used for validAudiences in the oauthProvider config that controls which audiences are accepted when issuing tokens). If the MCP endpoint path changes and only one copy is updated, JWT authentication silently breaks — Better Auth issues tokens with one audience but jwtVerify rejects them against the stale audience, and the only diagnostic is a [mcp] jwt verify failed log warning with no stack trace to the root cause.

Import MCP_AUDIENCE from @/lib/auth in this module and use it in place of the local MCP_RESOURCE for the audience parameter. No circular dependency exists between the two modules.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enkii enkii  ·  security review

Summary

Security review of OAuth discovery fixes for the MCP endpoint. Reviewed: (1) GET/DELETE on /api/mcp now emit a 401 + WWW-Authenticate challenge instead of a bare 405, enabling OAuth discovery; (2) protected resource metadata now advertises the correct authorization server URL (/api/auth); (3) JWT issuer verification now matches Better Auth's actual issuer; (4) new RFC 8414 path-aware auth server metadata route. No security findings: the authenticate() call added to GET/DELETE reuses the same verified function as POST, the WWW-Authenticate header leaks no secrets, and the issuer check was tightened (not loosened). Safe to merge.

Mergeability Score: 5/5

Safe to merge from this review's perspective.

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>
@github-actions
Copy link
Copy Markdown

enkii enkii  ·  code review

Working on this PR…

View job run

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enkii enkii  ·  code review

Summary

This PR fixes OAuth discovery for MCP web connector clients by addressing three layered issues: (1) adds the RFC 8414 path-aware AS metadata route at /.well-known/oauth-authorization-server/api/auth, (2) corrects the JWT iss verification from env.APP_URL to ${env.APP_URL}/api/auth matching Better Auth's actual issuer, and (3) makes unauthenticated GET/DELETE on /api/mcp return a 401 OAuth challenge instead of 405. The MCP_RESOURCE/MCP_AUDIENCE duplication is eliminated. All cross-references — issuer, audience, WWW-Authenticate header, protected-resource metadata, and AS metadata — are mutually consistent. Tests cover the new route behavior and updated JWT minting. No findings. Safe to merge.

Mergeability Score: 5/5

Safe to merge from this review's perspective.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enkii enkii  ·  security review

Summary

Security review of OAuth discovery and JWT verification fixes for the MCP endpoint. Changes are security-positive: JWT iss verification is tightened to match Better Auth's actual issuer (${APP_URL}/api/auth), the duplicated audience constant is unified via MCP_AUDIENCE import, GET/DELETE now emit proper OAuth challenge responses per RFC 9728, and the RFC 8414 path-aware authorization server metadata route is added. No exploitable vulnerabilities introduced — auth bypass, injection, SSRF, IDOR, and information disclosure paths were all traced and ruled out. Safe to merge.

Mergeability Score: 5/5

Safe to merge from this review's perspective.

@Timmyy3000 Timmyy3000 merged commit 4a9387f into main May 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant