From d46e986e0b0d7023a25d79c97d28e6a57b313d52 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:44:10 -0400 Subject: [PATCH] docs: explain OAuth HTTP client setup --- docs/OAUTH_SUPPORT.md | 178 ++++++++++++++++------ examples/clients/README.md | 1 + examples/clients/src/auth/oauth_client.rs | 12 +- 3 files changed, 140 insertions(+), 51 deletions(-) diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index dd32a17c1..16809a407 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -14,6 +14,7 @@ This document describes the OAuth 2.1 authorization implementation for Model Con - Scope upgrade on 403 insufficient_scope (SEP-835) - Automatic token refresh - Authorized HTTP Client implementation +- Injectable OAuth HTTP client for custom network environments ## Usage Guide @@ -26,86 +27,162 @@ Enable the auth feature in Cargo.toml: rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client-reqwest"] } ``` -### 2. Use OAuthState +### 2. Configure OAuth network requests + +OAuth makes several HTTP requests before the MCP transport is connected: +protected-resource discovery, authorization-server discovery, dynamic client +registration, authorization-code exchange, token refresh, and client credentials +exchange. When no OAuth HTTP client is provided, the SDK sends those requests +with an internally-created `reqwest::Client`. + +If you only need to customize reqwest behavior, pass a configured +`reqwest::Client` to `OAuthState::new`. This preserves the caller-provided +reqwest configuration across OAuth operations, including token requests. + +```rust ignore +let default_headers = reqwest::header::HeaderMap::new(); +let oauth_http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .default_headers(default_headers) + .build()?; + +let mut oauth_state = OAuthState::new(&server_url, Some(oauth_http_client)) + .await + .context("Failed to initialize oauth state machine")?; +``` + +This is useful for proxy, TLS root, connector, timeout, and default-header +configuration while staying within reqwest. The redirect behavior is the +behavior of the provided reqwest client, so configure that client accordingly. +This OAuth HTTP client is separate from the `reqwest::Client` later passed to +`AuthClient::new`, which is used for the authorized MCP transport after tokens +have been obtained. + +If OAuth requests must run outside reqwest, implement `OAuthHttpClient` and use +`OAuthState::new_with_oauth_http_client`. The SDK passes each OAuth request to +your implementation with the raw HTTP request, a suggested timeout, and an +`OAuthHttpRedirectPolicy`. + +```rust ignore +use std::sync::Arc; + +use rmcp::transport::{ + OAuthHttpClient, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, OAuthState, +}; + +struct MyOAuthHttpClient; + +impl OAuthHttpClient for MyOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async move { + match request.redirect_policy { + OAuthHttpRedirectPolicy::Follow => { + // Follow redirects according to your HTTP environment. + } + OAuthHttpRedirectPolicy::Stop => { + // Return redirect responses without following them. + } + _ => { + // Future redirect policies may be added. + } + } + + // Convert `request.request` into your HTTP stack's request type, + // execute it, then convert the response back into the expected + // OAuth HTTP response type. + let response = todo!("send OAuth request"); + Ok(response) + }) + } +} + +let mut oauth_state = OAuthState::new_with_oauth_http_client( + &server_url, + Arc::new(MyOAuthHttpClient), +) +.await?; +``` + +Use this path when OAuth traffic must go through a browser fetch API, a remote +execution environment, a company gateway, a test fake, or any other non-reqwest +transport. + +### 3. Start authorization with OAuthState The `OAuthState` state machine manages the full authorization lifecycle. When no scopes are provided, the SDK automatically selects scopes from the server's WWW-Authenticate header, Protected Resource Metadata, or AS metadata. ```rust ignore - // initialize oauth state machine - let mut oauth_state = OAuthState::new(&server_url, None) - .await - .context("Failed to initialize oauth state machine")?; - - // start authorization - pass empty scopes to let the SDK auto-select - oauth_state - .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) - .await - .context("Failed to start authorization")?; +// start authorization - pass empty scopes to let the SDK auto-select +oauth_state + .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) + .await + .context("Failed to start authorization")?; ``` If you know the scopes you need, you can still pass them explicitly: ```rust ignore - oauth_state - .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) - .await - .context("Failed to start authorization")?; +oauth_state + .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) + .await + .context("Failed to start authorization")?; ``` -### 3. Get authorization url and handle callback +### 4. Get authorization url and handle callback ```rust ignore - // get authorization URL and guide user to open it - let auth_url = oauth_state.get_authorization_url().await?; - println!("Please open the following URL in your browser for authorization:\n{}", auth_url); - - // handle callback - in real applications, this is typically done in a callback server - let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; - let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; - oauth_state.handle_callback(auth_code, csrf_token).await?; +// get authorization URL and guide user to open it +let auth_url = oauth_state.get_authorization_url().await?; +println!("Please open the following URL in your browser for authorization:\n{}", auth_url); + +// handle callback - in real applications, this is typically done in a callback server +let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; +let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; +oauth_state.handle_callback(auth_code, csrf_token).await?; ``` -### 4. Use Authorized Streamable HTTP Transport and create client +### 5. Use Authorized Streamable HTTP Transport and create client ```rust ignore - let am = oauth_state - .into_authorization_manager() - .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; - let client = AuthClient::new(reqwest::Client::default(), am); - let transport = StreamableHttpClientTransport::with_client( - client, - StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), - ); - - // create client and connect to MCP server - let client_service = ClientInfo::default(); - let client = client_service.serve(transport).await?; +let am = oauth_state + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; +let client = AuthClient::new(reqwest::Client::default(), am); +let transport = StreamableHttpClientTransport::with_client( + client, + StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), +); + +// create client and connect to MCP server +let client_service = ClientInfo::default(); +let client = client_service.serve(transport).await?; ``` -### 5. Handle scope upgrades +### 6. Handle scope upgrades If a server returns 403 with `insufficient_scope`, you can request a scope upgrade. The SDK computes the union of current and required scopes and transitions back to the session state for re-authorization. ```rust ignore - match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { - Ok(auth_url) => { - // open auth_url in browser, handle callback as before - println!("Re-authorize at: {}", auth_url); - } - Err(e) => { - eprintln!("Scope upgrade failed: {}", e); - } +match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { + Ok(auth_url) => { + // open auth_url in browser, handle callback as before + println!("Re-authorize at: {}", auth_url); + } + Err(e) => { + eprintln!("Scope upgrade failed: {}", e); } +} ``` ## Complete Examples -- **Client**: `examples/clients/src/auth/oauth_client.rs` -- **Server**: `examples/servers/src/complex_auth_streamhttp.rs` +- **Client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Server**: [`examples/servers/src/complex_auth_streamhttp.rs`](../examples/servers/src/complex_auth_streamhttp.rs) ### Running the Examples @@ -134,6 +211,7 @@ cargo run -p mcp-client-examples --example clients_oauth_client - **PKCE S256 always enforced**: never falls back to `plain` or no challenge. OAuth 2.1 mandates S256 as Mandatory To Implement for servers. - **RFC 8707 resource binding**: authorization and token requests include the `resource` parameter to bind tokens to the protected resource +- **Redirect policy is explicit for custom OAuth clients**: discovery and registration requests use `OAuthHttpRedirectPolicy::Follow`, while token requests use `OAuthHttpRedirectPolicy::Stop` so custom implementations can avoid forwarding credentials to redirected endpoints - All tokens are securely stored in memory (custom credential stores supported) - Automatic token refresh reduces user intervention - Server metadata validation warns on non-compliant configurations but proceeds where relatively safe @@ -147,7 +225,9 @@ If you encounter authorization issues, check the following: 3. Check network connection and firewall settings 4. Verify server supports metadata discovery or dynamic client registration 5. If PKCE fails, the server may not support S256 (non-compliant with OAuth 2.1) -6. Check `tracing` logs at debug level for detailed discovery and validation info +6. If OAuth requests need custom proxy, TLS, or connector settings, pass a configured reqwest client to `OAuthState::new` +7. If OAuth requests must run through a non-reqwest environment, implement `OAuthHttpClient` and use `OAuthState::new_with_oauth_http_client` +8. Check `tracing` logs at debug level for detailed discovery and validation info ## References diff --git a/examples/clients/README.md b/examples/clients/README.md index 4361d681d..419cdac22 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -45,6 +45,7 @@ A client demonstrating how to authenticate with an MCP server using OAuth. - Starts a local HTTP server to handle OAuth callbacks - Initializes the OAuth state machine and begins the authorization flow +- Shows how to pass a configured reqwest client for OAuth discovery, registration, token exchange, and refresh requests - Displays the authorization URL and waits for user authorization - Establishes an authorized connection to the MCP server using the acquired access token - Demonstrates how to use the authorized connection to retrieve available tools and prompts diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 7a2bfab00..ffd343428 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -1,4 +1,4 @@ -use std::{env, net::SocketAddr, sync::Arc}; +use std::{env, net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{Context, Result}; use axum::{ @@ -115,8 +115,16 @@ async fn main() -> Result<()> { client_metadata_url ); + // Configure the HTTP client used for OAuth discovery, registration, token + // exchange, and refresh. Customize this builder for proxies, TLS roots, + // default headers, or other reqwest settings required by your environment. + let oauth_http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to build OAuth HTTP client")?; + // initialize oauth state machine - let mut oauth_state = OAuthState::new(&server_url, None) + let mut oauth_state = OAuthState::new(&server_url, Some(oauth_http_client)) .await .context("Failed to initialize oauth state machine")?; // use CIMD (SEP-991) with client metadata URL.