Skip to content

feat(mcp): dynamic client registration and bearer injection for OAuth MCP servers - #1029

Open
mariomosca wants to merge 1 commit into
iOfficeAI:mainfrom
mariomosca:feat/mcp-oauth-dcr-headers
Open

mariomosca wants to merge 1 commit into
iOfficeAI:mainfrom
mariomosca:feat/mcp-oauth-dcr-headers

Conversation

@mariomosca

Copy link
Copy Markdown

Problem

MCP servers that implement the MCP authorization spec cannot be used from AionUi today. Two independent gaps:

  1. No dynamic client registration. McpOAuthService always sends the hardcoded client_id = "aionui", and discovery only probes .well-known/oauth-authorization-server on the MCP server URL itself. A spec-compliant authorization server answers /authorize with invalid_client, and the loopback redirect (http://127.0.0.1:<random port>/callback) can never be allow-listed up front — registration at login time is exactly what RFC 7591 exists for, and what the MCP spec prescribes.
  2. The token was never used. Even after a successful login, the stored access token was read by nothing: the MCP servers handed to a backend carried only the headers the user typed by hand, so the agent CLI called the server anonymously and got a 401.

Concretely: an internal MCP server behind an OAuth 2.1 authorization server (Entra ID upstream, protected resource metadata, mandatory registration, per-client redirect allow-list) is unusable from AionUi, with or without a completed login.

What this changes

Discovery (oauth_discovery.rs) — the chain the MCP spec describes: protected resource metadata (RFC 9728) → authorization server metadata (RFC 8414) / OIDC discovery, trying the path-aware, path-suffixed and origin forms in that order. The previous behaviour (probing the MCP server URL as its own authorization server) is kept as the last fallback, so servers that publish neither keep working unchanged.

Dynamic client registration (oauth_registration.rs) — when the authorization server advertises a registration_endpoint, the client registers itself for the loopback redirect URI it is about to listen on (token_endpoint_auth_method: "none", grant_types: [authorization_code, refresh_token], native application type). Without a registration endpoint, the static client id is used exactly as before.

Client persistence — the issued client_id (and secret, if any) is stored next to the token (migration 045), because the refresh grant has to present the same client that obtained the token. Nothing is persisted for the static fallback.

Resource indicator — when protected resource metadata declares a resource, it is sent as the RFC 8707 resource parameter on the authorize, token and refresh requests, so the authorization server can audience-restrict the token to that MCP server.

Bearer injection (oauth_injection.rs) — at session-build time, every http/sse MCP server the user authenticated against is handed to the backend with Authorization: Bearer <token> (refreshed if expired). Covered lanes: the direct-CLI session path (claude/codex/agy, via SessionBuildInputs.mcp_token_provider) and the ACP path (session/new mcpServers). A header the user configured by hand is never overwritten, and a server with no stored token is left untouched — injection is best effort and never fails a session build. Tokens are resolved per session build and never written into the persisted conversation snapshot.

The McpOAuthService is now built once in AppServices, so the instance the MCP routes log in with is the one the agent factory injects tokens from.

Tests

  • Unit: well-known URL derivation (path-aware / path-suffixed / origin), protected resource + authorization server metadata parsing, registration request shape and response parsing, bearer header application (including the case-insensitive "user header wins" rule).
  • Integration (oauth_dcr_integration.rs), against a fake authorization server in-process: discovery follows PRM → AS; registration sends the loopback redirect and returns the client id; a registration failure surfaces the server's own error and aborts the login before any browser opens; the refresh grant presents the registered client id and the resource indicator, and persists both; a server without a registration endpoint falls back to the static client id.
  • session_agent: the inline --mcp-config the claude CLI is spawned with actually carries Authorization: Bearer … for an authenticated http MCP server, carries nothing when no token provider is wired, and keeps a user-configured Authorization header untouched.
  • Manual, opt-in (oauth_live_manual.rs, #[ignore]): full flow against a real server, driven by AIONUI_LIVE_MCP_URL.

Verified end-to-end against a real MCP server (OAuth 2.1 authorization server proxying Entra ID): discovery → dynamic registration → browser login → token stored with the registered client id → authenticated initialize + tools/list + tools/call over the streamable HTTP transport.

cargo fmt --check and cargo clippy --workspace --all-targets -- -D warnings are clean; cargo test -p aionui-mcp -p aionui-db -p aionui-ai-agent passes locally. The full workspace suite is left to CI.

🤖 Generated with Claude Code

… MCP servers

MCP servers implementing the MCP authorization spec could not be used from
AionUi: the OAuth client id was hardcoded, so a spec-compliant authorization
server rejected the flow with `invalid_client` and could never allow-list the
loopback redirect URI; and the token obtained by a successful login was read by
nothing, so the agent CLI still called the server anonymously.

Discovery now follows protected resource metadata (RFC 9728) to the
authorization server metadata (RFC 8414 / OIDC), keeping the previous probe as
the last fallback. When the server advertises a registration endpoint the
client registers itself for the loopback redirect it is about to listen on
(RFC 7591); the issued client id and secret are persisted next to the token
(migration 045) because the refresh grant must present the same client. When
protected resource metadata declares a resource, it is sent as the RFC 8707
resource indicator on authorize, token and refresh requests.

At session-build time every authenticated http/sse MCP server reaches the
backend with an `Authorization: Bearer` header, on the direct-CLI, ACP and
aionrs lanes. A header the user configured by hand is never overwritten, a
server with no token is left untouched, and tokens are resolved per build
rather than persisted into the conversation snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kelvinymoura

Copy link
Copy Markdown

Follow-up PR against this branch: mariomosca#1. It fixes three gaps found while testing this PR end to end against https://mcp.cloudflare.com/mcp:

  • POST /api/mcp/test-connection now uses the stored OAuth token, so the Tools page no longer shows "authentication required" after a successful login.
  • OAuth discovery and dynamic client registration failures return 502 (MCP_OAUTH_DISCOVERY_FAILED / MCP_OAUTH_REGISTRATION_FAILED) with the real reason instead of a generic 500.
  • oauth_live_manual no longer requires Mcp-Session-Id; Cloudflare's server is stateless and sends none.

Verified live against Cloudflare and in AionUi's Tools page. Happy to adjust anything.

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.

3 participants