Skip to content

feat(mcp): wire up oauth2 token verification for streamable-http transport#284

Open
Piotr1215 wants to merge 1 commit intoredis:mainfrom
Piotr1215:feat/mcp-oauth-token-verifier
Open

feat(mcp): wire up oauth2 token verification for streamable-http transport#284
Piotr1215 wants to merge 1 commit intoredis:mainfrom
Piotr1215:feat/mcp-oauth-token-verifier

Conversation

@Piotr1215
Copy link
Copy Markdown
Contributor

@Piotr1215 Piotr1215 commented Apr 16, 2026

Problem

The MCP SDK (v1.26.0+) has built-in OAuth 2.1 support via AuthSettings, TokenVerifier, and RequireAuthMiddleware. The agent-memory-server's existing auth.py handles JWT validation for the REST API (/v1/* endpoints), but the MCP streamable-http transport (/mcp endpoint) never wires this up to the SDK's auth layer.

When an MCP client (e.g., Claude) connects to /mcp without a Bearer token, the transport returns a 406 Not Acceptable error instead of 401 with a WWW-Authenticate header. Without that header, clients cannot discover the OAuth authorization server and the login flow never triggers.

The REST API and MCP transport are two separate ASGI apps — the FastAPI auth middleware on the REST side does not protect the MCP side.

Fix

Three small changes:

1. JWTTokenVerifier in mcp.py

Implements the SDK's TokenVerifier protocol by wrapping the existing verify_jwt() function from auth.py. Converts UserInfo (the REST API's auth model) to AccessToken (the SDK's auth model).

2. Conditional AuthSettings in mcp.py

_build_mcp_auth_kwargs() returns auth + token_verifier kwargs for FastMCP only when AUTH_MODE=oauth2 and OAUTH2_RESOURCE_HOST are set. When disabled, no kwargs are passed and behavior is unchanged.

When enabled, the SDK's built-in middleware:

  • Returns 401 with WWW-Authenticate: Bearer resource_metadata="..." for unauthenticated requests
  • Serves /.well-known/oauth-protected-resource automatically
  • Validates JWT tokens on all MCP requests via JWKS

3. /.well-known/oauth-authorization-server endpoint in main.py

RFC 9728 Protected Resource Metadata endpoint on the REST API side. Returns the resource server URL and authorization server URL so clients can discover where to authenticate.

4. OAUTH2_RESOURCE_HOST config setting

New optional setting for the server's public hostname (e.g., agent-memory.loft.rocks). Used to construct the resource field in metadata responses.

Configuration

# Enable MCP OAuth (in addition to existing REST API OAuth2 settings)
AUTH_MODE=oauth2
OAUTH2_ISSUER_URL=https://your-oidc-provider.example.com/
OAUTH2_RESOURCE_HOST=your-server.example.com
# Optional: OAUTH2_AUDIENCE for JWT audience validation (existing setting)

When AUTH_MODE is not oauth2 or OAUTH2_RESOURCE_HOST is unset, the MCP transport runs without auth — same as before this change.

Test plan

  • 53/53 existing auth tests pass
  • _build_mcp_auth_kwargs() returns correct settings when configured
  • _build_mcp_auth_kwargs() returns empty dict when unconfigured
  • curl -H "Accept: text/event-stream" https://server/mcp returns 401 + WWW-Authenticate header
  • MCP client (Claude) discovers OAuth flow and completes login
  • After login, MCP tools work with valid JWT

Tested with Ory Hydra as the OIDC provider and Claude as the MCP client.


Note

Medium Risk
Touches authentication and request routing for the MCP transport; misconfiguration or verifier/middleware integration issues could cause unexpected 401/406 behavior or block clients from connecting.

Overview
Wires the MCP streamable-http transport into the MCP SDK’s OAuth2 auth layer by conditionally passing AuthSettings and a new JWTTokenVerifier that adapts the server’s existing verify_jwt() validation to the SDK’s TokenVerifier interface.

Adds a new /.well-known/oauth-authorization-server endpoint on the REST app to publish protected-resource/authorization-server metadata (using new oauth2_resource_host config), and updates the MCP streamable_http_app() override to extend the parent app so auth middleware and well-known routes are preserved while still supporting namespace-prefixed routing.

Reviewed by Cursor Bugbot for commit 1f5dc32. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI review requested due to automatic review settings April 16, 2026 15:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR wires OAuth2 token verification into the MCP streamable-http transport so MCP clients receive proper 401 + WWW-Authenticate challenges and can complete OIDC discovery/login flows, aligning MCP auth behavior with the server’s existing JWT verification logic.

Changes:

  • Adds an MCP SDK TokenVerifier implementation that wraps the server’s existing verify_jwt() logic.
  • Conditionally enables MCP SDK auth middleware via AuthSettings when OAuth2 is configured.
  • Adds a well-known metadata endpoint and a new OAUTH2_RESOURCE_HOST setting for constructing resource URLs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
agent_memory_server/mcp.py Adds JWTTokenVerifier and conditional auth wiring into FastMCP initialization.
agent_memory_server/main.py Adds a well-known OAuth discovery/metadata endpoint for clients.
agent_memory_server/config.py Introduces oauth2_resource_host configuration used for resource URL construction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +136 to +141
if not settings.oauth2_issuer_url:
return {"error": "OIDC not configured"}
return {
"resource": f"https://{settings.oauth2_resource_host or 'localhost'}",
"authorization_servers": [settings.oauth2_issuer_url],
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

When OIDC isn't configured (or oauth2_resource_host is unset), this endpoint returns a 200 with either an error object or resource defaulting to https://localhost. That can cause clients to cache/act on invalid metadata. Consider returning an appropriate non-2xx status (e.g., 404/503 via HTTPException) and requiring settings.oauth2_resource_host to be set before serving metadata (instead of defaulting).

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +276
try:
user_info = verify_jwt(token)
scopes = user_info.scope.split() if user_info.scope else []
return AccessToken(
token=token,
client_id=user_info.sub,
scopes=scopes,
expires_at=user_info.exp,
)
except Exception:
return None
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

verify_token() catches Exception and returns None, which will silently convert internal errors (e.g., misconfiguration, JWKS fetch failures) into an auth failure and make diagnosis difficult. Consider catching only expected auth-related exceptions (e.g., FastAPI HTTPException/JWT validation errors) and logging them; let unexpected exceptions propagate so they surface as 5xx with logs.

Copilot uses AI. Check for mistakes.
Comment on lines +264 to +267
from agent_memory_server.auth import verify_jwt

try:
user_info = verify_jwt(token)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

JWTTokenVerifier.verify_token() is async but calls the synchronous verify_jwt(), which performs blocking I/O (httpx.Client().get(...) inside JWKSCache.get_jwks). In the MCP streamable-http transport this can block the event loop under load. Consider running verify_jwt() in a thread (e.g., anyio.to_thread.run_sync) or refactoring JWKS fetching/verification to an async path for MCP.

Suggested change
from agent_memory_server.auth import verify_jwt
try:
user_info = verify_jwt(token)
from anyio import to_thread
from agent_memory_server.auth import verify_jwt
try:
user_info = await to_thread.run_sync(verify_jwt, token)

Copilot uses AI. Check for mistakes.
Comment on lines +281 to +286
if (
settings.auth_mode != "oauth2"
or not settings.oauth2_issuer_url
or not settings.oauth2_resource_host
):
return {}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

_build_mcp_auth_kwargs() gates MCP auth on auth_mode and OAuth2 settings, but it ignores settings.disable_auth. That can lead to the REST API running with auth disabled while MCP enforces OAuth (or vice versa), which is surprising given disable_auth is treated as the global kill switch in auth.get_current_user(). Consider including settings.disable_auth in this guard (or aligning on a single source of truth for enabling auth).

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +141
@app.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server_metadata():
"""RFC 9728 Protected Resource Metadata for MCP OIDC discovery."""
if not settings.oauth2_issuer_url:
return {"error": "OIDC not configured"}
return {
"resource": f"https://{settings.oauth2_resource_host or 'localhost'}",
"authorization_servers": [settings.oauth2_issuer_url],
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

This route is registered at /.well-known/oauth-authorization-server, but the response body and docstring are for RFC 9728 Protected Resource Metadata (resource + authorization_servers). Clients following RFC 9728 will look for /.well-known/oauth-protected-resource (and RFC 8414 uses different fields for oauth-authorization-server). As written, discovery is likely to fail because the well-known path doesn't match the payload type.

Copilot uses AI. Check for mistakes.
return {
"resource": f"https://{settings.oauth2_resource_host or 'localhost'}",
"authorization_servers": [settings.oauth2_issuer_url],
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong well-known path for RFC 9728 metadata

High Severity

The endpoint path /.well-known/oauth-authorization-server is the RFC 8414 Authorization Server Metadata path, but the response body contains RFC 9728 Protected Resource Metadata fields (resource, authorization_servers). The PR description and docstring both say this is an "RFC 9728 Protected Resource Metadata" endpoint, so the correct path is /.well-known/oauth-protected-resource. MCP clients performing RFC 9728 discovery will look for the resource metadata at the correct path and won't find it here.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e0b5c7. Configure here.

async def oauth_authorization_server_metadata():
"""RFC 9728 Protected Resource Metadata for MCP OIDC discovery."""
if not settings.oauth2_issuer_url:
return {"error": "OIDC not configured"}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Error response returns HTTP 200 instead of error status

Medium Severity

When oauth2_issuer_url is not configured, oauth_authorization_server_metadata returns {"error": "OIDC not configured"} with an implicit HTTP 200 status. Clients will interpret this as valid metadata and attempt to parse the resource and authorization_servers fields, leading to confusing failures downstream. An appropriate HTTP error status (e.g., 404 or 501) would correctly signal that the metadata is unavailable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e0b5c7. Configure here.

expires_at=user_info.exp,
)
except Exception:
return None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sync HTTP call blocks async event loop

Low Severity

JWTTokenVerifier.verify_token is an async method that calls the synchronous verify_jwt() directly. Internally, verify_jwt uses httpx.Client (synchronous) to fetch JWKS keys, which can block the event loop for up to 10 seconds during cache misses. In the REST API, FastAPI runs sync dependencies in a thread pool automatically, but here the sync call runs directly on the event loop. Using asyncio.to_thread or similar would avoid the blocking.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e0b5c7. Configure here.

…sport

the mcp sdk (v1.26.0+) has built-in oauth 2.1 support via AuthSettings
and TokenVerifier, but the agent-memory-server never wired it up for the
streamable-http transport. without it, unauthenticated requests to /mcp
return 406 instead of 401 + WWW-Authenticate header, which prevents mcp
clients (like claude) from discovering the oauth flow.

changes:

- add JWTTokenVerifier that implements the sdk's TokenVerifier protocol
  by wrapping the existing verify_jwt() function
- conditionally pass AuthSettings + token_verifier to FastMCP when
  AUTH_MODE=oauth2 and OAUTH2_RESOURCE_HOST are set
- add /.well-known/oauth-authorization-server endpoint (RFC 9728) to
  the REST API for protected resource metadata discovery
- add OAUTH2_RESOURCE_HOST config setting for the server's public hostname

when enabled, the mcp transport now:
- returns 401 with WWW-Authenticate: Bearer resource_metadata="..." header
- serves /.well-known/oauth-protected-resource automatically (sdk built-in)
- validates jwt tokens on all mcp requests via jwks

when AUTH_MODE != oauth2 or OAUTH2_RESOURCE_HOST is unset, behavior is
unchanged — no auth kwargs are passed to FastMCP.

tested with ory hydra as the oidc provider and claude as the mcp client.
@Piotr1215 Piotr1215 force-pushed the feat/mcp-oauth-token-verifier branch from 2e0b5c7 to 1f5dc32 Compare April 16, 2026 16:05
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 1f5dc32. Configure here.

return {
"resource": f"https://{settings.oauth2_resource_host or 'localhost'}",
"authorization_servers": [settings.oauth2_issuer_url],
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing guard allows incorrect localhost resource metadata

Medium Severity

The guard condition in oauth_authorization_server_metadata only checks oauth2_issuer_url, while _build_mcp_auth_kwargs requires all three: auth_mode == "oauth2", oauth2_issuer_url, and oauth2_resource_host. This inconsistency means the endpoint can serve metadata with resource: "https://localhost" when oauth2_resource_host is unset, producing incorrect metadata that would misdirect clients. It also serves OAuth metadata even when auth_mode is "disabled" or "token".

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f5dc32. Configure here.

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.

2 participants