feat(mcp): wire up oauth2 token verification for streamable-http transport#284
feat(mcp): wire up oauth2 token verification for streamable-http transport#284Piotr1215 wants to merge 1 commit intoredis:mainfrom
Conversation
There was a problem hiding this comment.
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
TokenVerifierimplementation that wraps the server’s existingverify_jwt()logic. - Conditionally enables MCP SDK auth middleware via
AuthSettingswhen OAuth2 is configured. - Adds a well-known metadata endpoint and a new
OAUTH2_RESOURCE_HOSTsetting 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.
| 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], | ||
| } |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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.
| from agent_memory_server.auth import verify_jwt | ||
|
|
||
| try: | ||
| user_info = verify_jwt(token) |
There was a problem hiding this comment.
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.
| 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) |
| if ( | ||
| settings.auth_mode != "oauth2" | ||
| or not settings.oauth2_issuer_url | ||
| or not settings.oauth2_resource_host | ||
| ): | ||
| return {} |
There was a problem hiding this comment.
_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).
| @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], | ||
| } |
There was a problem hiding this comment.
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.
| return { | ||
| "resource": f"https://{settings.oauth2_resource_host or 'localhost'}", | ||
| "authorization_servers": [settings.oauth2_issuer_url], | ||
| } |
There was a problem hiding this comment.
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.
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"} |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 2e0b5c7. Configure here.
| expires_at=user_info.exp, | ||
| ) | ||
| except Exception: | ||
| return None |
There was a problem hiding this comment.
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.
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.
2e0b5c7 to
1f5dc32
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
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], | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 1f5dc32. Configure here.


Problem
The MCP SDK (v1.26.0+) has built-in OAuth 2.1 support via
AuthSettings,TokenVerifier, andRequireAuthMiddleware. The agent-memory-server's existingauth.pyhandles JWT validation for the REST API (/v1/*endpoints), but the MCP streamable-http transport (/mcpendpoint) never wires this up to the SDK's auth layer.When an MCP client (e.g., Claude) connects to
/mcpwithout a Bearer token, the transport returns a406 Not Acceptableerror instead of401with aWWW-Authenticateheader. 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.
JWTTokenVerifierinmcp.pyImplements the SDK's
TokenVerifierprotocol by wrapping the existingverify_jwt()function fromauth.py. ConvertsUserInfo(the REST API's auth model) toAccessToken(the SDK's auth model).2. Conditional
AuthSettingsinmcp.py_build_mcp_auth_kwargs()returnsauth+token_verifierkwargs forFastMCPonly whenAUTH_MODE=oauth2andOAUTH2_RESOURCE_HOSTare set. When disabled, no kwargs are passed and behavior is unchanged.When enabled, the SDK's built-in middleware:
401withWWW-Authenticate: Bearer resource_metadata="..."for unauthenticated requests/.well-known/oauth-protected-resourceautomatically3.
/.well-known/oauth-authorization-serverendpoint inmain.pyRFC 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_HOSTconfig settingNew optional setting for the server's public hostname (e.g.,
agent-memory.loft.rocks). Used to construct theresourcefield in metadata responses.Configuration
When
AUTH_MODEis notoauth2orOAUTH2_RESOURCE_HOSTis unset, the MCP transport runs without auth — same as before this change.Test plan
_build_mcp_auth_kwargs()returns correct settings when configured_build_mcp_auth_kwargs()returns empty dict when unconfiguredcurl -H "Accept: text/event-stream" https://server/mcpreturns 401 +WWW-AuthenticateheaderTested 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-httptransport into the MCP SDK’s OAuth2 auth layer by conditionally passingAuthSettingsand a newJWTTokenVerifierthat adapts the server’s existingverify_jwt()validation to the SDK’sTokenVerifierinterface.Adds a new
/.well-known/oauth-authorization-serverendpoint on the REST app to publish protected-resource/authorization-server metadata (using newoauth2_resource_hostconfig), and updates the MCPstreamable_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.