Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ rmcp = { path = "../crates/rmcp", features = [
"elicitation",
"auth",
"auth-client-credentials-jwt",
"auth-enterprise-managed",
"request-state",
"transport-streamable-http-server",
"transport-streamable-http-client-reqwest",
Expand All @@ -31,6 +32,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = { version = "0.8", features = ["macros"] }
anyhow = "1"
oauth2 = { version = "5.0", default-features = false }
reqwest = { version = "0.13", features = ["json"] }
urlencoding = "2"
url = "2"
81 changes: 80 additions & 1 deletion conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use anyhow::Context;
use oauth2::{ClientSecret, RefreshToken};
use rmcp::{
ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
model::*,
Expand All @@ -6,7 +8,8 @@ use rmcp::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, default_oauth_http_client,
enterprise::{EmaAuthorizationServer, EmaClientAuthentication, EmaExchangeRequest},
},
streamable_http_client::StreamableHttpClientTransportConfig,
},
Expand Down Expand Up @@ -36,6 +39,17 @@ struct ConformanceContext {
private_key_pem: Option<String>,
#[serde(default)]
signing_algorithm: Option<String>,
// enterprise-managed-authorization-refresh-token
#[serde(default)]
idp_client_id: Option<String>,
#[serde(default)]
idp_client_secret: Option<String>,
#[serde(default)]
idp_refresh_token: Option<String>,
#[serde(default)]
idp_issuer: Option<String>,
#[serde(default)]
idp_token_endpoint: Option<String>,
}

fn load_context() -> ConformanceContext {
Expand Down Expand Up @@ -760,6 +774,66 @@ async fn run_client_credentials_jwt(
Ok(())
}

/// Exchange the fixture's IdP refresh token, then exercise authenticated MCP access.
async fn run_ema_refresh_token_client(
server_url: &str,
ctx: &ConformanceContext,
) -> anyhow::Result<()> {
let manager = AuthorizationManager::new(server_url).await?;
let metadata = manager.resolve_metadata().await?.metadata;
let idp = EmaAuthorizationServer::new(
ctx.idp_issuer.as_deref().context("Missing idp_issuer")?,
ctx.idp_token_endpoint
.as_deref()
.context("Missing idp_token_endpoint")?,
ctx.idp_client_id
.as_deref()
.context("Missing idp_client_id")?,
)
.with_client_authentication(EmaClientAuthentication::ClientSecretBasic(
ClientSecret::new(
ctx.idp_client_secret
.clone()
.context("Missing idp_client_secret")?,
),
));
let resource_as = EmaAuthorizationServer::new(
metadata
.issuer
.context("Missing authorization server issuer")?,
metadata.token_endpoint,
ctx.client_id.as_deref().context("Missing client_id")?,
)
.with_client_authentication(EmaClientAuthentication::ClientSecretBasic(
ClientSecret::new(ctx.client_secret.clone().context("Missing client_secret")?),
));
let refresh_token = RefreshToken::new(
ctx.idp_refresh_token
.clone()
.context("Missing idp_refresh_token")?,
);
let http = default_oauth_http_client()?;
let token = EmaExchangeRequest::new(idp, resource_as, server_url, &refresh_token)
.with_scopes(manager.select_scopes(None, &[]))
.exchange(&http, &http)
.await?;

let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(server_url)
.auth_header(token.access_token.secret()),
);
let client = BasicClientHandler
.serve_with_lifecycle(transport, conformance_lifecycle())
.await?;
let tools = client.list_tools(Default::default()).await?;
for tool in tools.tools {
let args = build_tool_arguments(&tool);
client.call_tool(call_tool_params(tool.name, args)).await?;
}
client.cancel().await?;
Ok(())
}

/// Cross-app access flow (SEP-1046 extension).
async fn run_cross_app_access_client(
server_url: &str,
Expand Down Expand Up @@ -1110,6 +1184,11 @@ async fn run_scenario(
"auth/client-credentials-basic" => run_client_credentials_basic(server_url, ctx).await?,
"auth/client-credentials-jwt" => run_client_credentials_jwt(server_url, ctx).await?,

// Auth - enterprise-managed authorization with a refresh-token subject
"auth/enterprise-managed-authorization-refresh-token" => {
run_ema_refresh_token_client(server_url, ctx).await?
}

// Auth - cross-app access
"auth/cross-app-access-complete-flow" => {
run_cross_app_access_client(server_url, ctx).await?
Expand Down
4 changes: 3 additions & 1 deletion crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ exhaustive_enums = "warn"
features = [
"auth",
"auth-client-credentials-jwt",
"auth-enterprise-managed",
"base64",
"client",
"client-side-sse",
Expand Down Expand Up @@ -196,10 +197,11 @@ transport-streamable-http-server-session = [
tower = ["dep:tower-service"]
auth = ["dep:async-trait", "dep:oauth2", "__reqwest", "dep:url"]
auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"]
auth-enterprise-managed = ["auth", "base64"]
schemars = ["dep:schemars"]

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["full", "test-util"] }
schemars = { version = "1.1.0", features = ["chrono04"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
hyper = { version = "1", features = ["server", "http1"] }
Expand Down
5 changes: 5 additions & 0 deletions crates/rmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ For **getting started**, **usage guides**, and **full MCP feature documentation*
| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ |
| `schemars` | JSON Schema generation for tool definitions | |
| `auth` | OAuth 2.0 authentication support | |
| `auth-enterprise-managed` | EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients (includes `auth`) | |
| `elicitation` | Elicitation support | |

### Transport features
Expand All @@ -45,6 +46,10 @@ For **getting started**, **usage guides**, and **full MCP feature documentation*
| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) |
| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) |

For enterprise-managed authorization, enable `auth-enterprise-managed` and a TLS
backend such as `reqwest`. See the [EMA/XAA guide](../../docs/OAUTH_SUPPORT.md#enterprise-managed-authorization-emaxaa)
for client authentication and an MCP connection example.

## Transports

The transport layer is pluggable. Two built-in pairs cover the most common cases:
Expand Down
74 changes: 69 additions & 5 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ use tracing::{debug, warn};

use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION;

#[cfg(feature = "auth-enterprise-managed")]
pub mod enterprise;

const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10;
Expand Down Expand Up @@ -99,6 +102,19 @@ pub trait OAuthHttpClient: Send + Sync {
fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_>;
}

/// Create an OAuth HTTP client with the SDK's default reqwest configuration.
///
/// Honors each request's redirect policy, with a 30-second timeout and bounded
/// response bodies. Enable a TLS feature such as `reqwest` for HTTPS requests.
/// Implement [`OAuthHttpClient`] instead when custom network policy is required.
pub fn default_oauth_http_client() -> Result<impl OAuthHttpClient, AuthError> {
let client = ReqwestClient::builder()
.timeout(DEFAULT_HTTP_TIMEOUT)
.build()
.map_err(|error| AuthError::InternalError(error.to_string()))?;
ReqwestOAuthHttpClient::new(client)
}

struct ReqwestOAuthHttpClient {
follow_redirects: ReqwestClient,
stop_redirects: ReqwestClient,
Expand Down Expand Up @@ -1307,13 +1323,9 @@ impl AuthorizationManager {

/// create new auth manager with base url
pub async fn new<U: IntoUrl>(base_url: U) -> Result<Self, AuthError> {
let http_client = ReqwestClient::builder()
.timeout(DEFAULT_HTTP_TIMEOUT)
.build()
.map_err(|e| AuthError::InternalError(e.to_string()))?;
Self::new_inner(
base_url,
Arc::new(ReqwestOAuthHttpClient::new(http_client)?),
Arc::new(default_oauth_http_client()?),
OAuthHttpRedirectPolicy::Stop,
)
.await
Expand Down Expand Up @@ -4043,6 +4055,58 @@ mod tests {
);
}

#[tokio::test]
async fn default_oauth_http_client_honors_redirect_policy() {
use axum::{Router, routing::post};

let received = Arc::new(StdMutex::new(Vec::new()));
let capture = Arc::clone(&received);
let app = Router::new()
.route(
"/redirect",
post(|| async { (StatusCode::TEMPORARY_REDIRECT, [("location", "/token")]) }),
)
.route(
"/token",
post(move |body: String| {
let capture = Arc::clone(&capture);
async move {
capture.lock().unwrap().push(body);
StatusCode::OK
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}/redirect", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let client = super::default_oauth_http_client().unwrap();

for (policy, expected) in [
(
OAuthHttpRedirectPolicy::Stop,
StatusCode::TEMPORARY_REDIRECT,
),
(OAuthHttpRedirectPolicy::Follow, StatusCode::OK),
] {
let request = oauth2::http::Request::builder()
.method("POST")
.uri(&endpoint)
.body(b"credential-sentinel".to_vec())
.unwrap();
let response = client
.execute(OAuthHttpRequest::new(request, policy))
.await
.unwrap();
assert_eq!(response.status(), expected);
let expected_bodies = if policy == OAuthHttpRedirectPolicy::Stop {
vec![]
} else {
vec!["credential-sentinel".to_owned()]
};
assert_eq!(*received.lock().unwrap(), expected_bodies);
}
}

#[tokio::test]
async fn default_http_client_preserves_connection_failure_cause() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading