From 1fb0ef3c0703373960cb856146ec8cd833eda4a2 Mon Sep 17 00:00:00 2001 From: tanish111 Date: Wed, 3 Dec 2025 00:07:06 +0530 Subject: [PATCH 1/7] feat(auth): add cimd support for SEP-991 add cimd support for url-based client ids Signed-off-by: tanish111 --- crates/rmcp/src/transport/auth.rs | 103 +++++++++++++++++----- examples/clients/src/auth/oauth_client.rs | 7 +- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 68dfc208..c31932f8 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -239,6 +239,15 @@ struct AuthorizationState { csrf_token: CsrfToken, } +/// SEP-991: URL-based Client IDs +/// Validate that the client_id is a valid URL with https scheme and non-root pathname +fn is_https_url(value: &str) -> bool { + match Url::parse(value) { + Ok(url) => url.scheme() == "https" && url.path() != "/", + Err(_) => false, + } +} + impl AuthorizationManager { fn well_known_paths(base_path: &str, resource: &str) -> Vec { let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); @@ -950,30 +959,60 @@ impl AuthorizationSession { scopes: &[&str], redirect_uri: &str, client_name: Option<&str>, + client_metadata_url: Option<&str>, ) -> Result { - // Default client config - let config = OAuthClientConfig { - client_id: "mcp-client".to_string(), - client_secret: None, - scopes: scopes.iter().map(|s| s.to_string()).collect(), - redirect_uri: redirect_uri.to_string(), - }; - - // try to dynamic register client - let config = match auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) - .await - { - Ok(config) => config, - Err(e) => { - warn!( - "Dynamic registration failed: {}, fallback to default config", - e - ); - // fallback to default config - config + let metadata = auth_manager.metadata.as_ref(); + let supports_url_based_client_id = metadata + .and_then(|m| m.additional_fields.get("client_id_metadata_document_supported")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let config = if supports_url_based_client_id { + if let Some(client_metadata_url) = client_metadata_url { + if !is_https_url(client_metadata_url) { + return Err(AuthError::RegistrationFailed(format!( + "client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}", + client_metadata_url + ))); + } + // SEP-991: URL-based Client IDs - use URL as client_id directly + OAuthClientConfig { + client_id: client_metadata_url.to_string(), + client_secret: None, + scopes: scopes.iter().map(|s| s.to_string()).collect(), + redirect_uri: redirect_uri.to_string(), + } + } else { + // Fallback to dynamic registration + match auth_manager + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .await + { + Ok(config) => config, + Err(e) => { + return Err(AuthError::RegistrationFailed(format!( + "Dynamic registration failed: {}", + e + ))); + } + } + } + } else { + // Fallback to dynamic registration + match auth_manager + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .await + { + Ok(config) => config, + Err(e) => { + return Err(AuthError::RegistrationFailed(format!( + "Dynamic registration failed: {}", + e + ))); + } } }; + // reset client config auth_manager.configure_client(config)?; let auth_url = auth_manager.get_authorization_url(scopes).await?; @@ -1125,6 +1164,18 @@ impl OAuthState { scopes: &[&str], redirect_uri: &str, client_name: Option<&str>, + ) -> Result<(), AuthError> { + self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None) + .await + } + + /// start authorization with optional client metadata URL (SEP-991) + pub async fn start_authorization_with_metadata_url( + &mut self, + scopes: &[&str], + redirect_uri: &str, + client_name: Option<&str>, + client_metadata_url: Option<&str>, ) -> Result<(), AuthError> { if let OAuthState::Unauthorized(mut manager) = std::mem::replace( self, @@ -1134,8 +1185,14 @@ impl OAuthState { let metadata = manager.discover_metadata().await?; manager.metadata = Some(metadata); debug!("start session"); - let session = - AuthorizationSession::new(manager, scopes, redirect_uri, client_name).await?; + let session = AuthorizationSession::new( + manager, + scopes, + redirect_uri, + client_name, + client_metadata_url, + ) + .await?; *self = OAuthState::Session(session); Ok(()) } else { diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 53fa5cca..bd43df91 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -79,6 +79,7 @@ async fn main() -> Result<()> { let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT)); tracing::info!("Starting callback server at: http://{}", addr); + tracing::warn!("Note: Callback server may not receive callbacks if redirect URI doesn't match localhost"); // Start server in a separate task tokio::spawn(async move { @@ -98,11 +99,15 @@ async fn main() -> Result<()> { let mut oauth_state = OAuthState::new(&server_url, None) .await .context("Failed to initialize oauth state machine")?; + + tracing::info!("Using CIMD (SEP-991) with client metadata URL: {}", CLIENT_METADATA_URL); + // Use CIMD (SEP-991) with client metadata URL oauth_state - .start_authorization( + .start_authorization_with_metadata_url( &["mcp", "profile", "email"], MCP_REDIRECT_URI, Some("Test MCP Client"), + Some(CLIENT_METADATA_URL), ) .await .context("Failed to start authorization")?; From 2784a6dcf1e1e432dbf0ebe836c385e566dfe589 Mon Sep 17 00:00:00 2001 From: tanish111 Date: Thu, 4 Dec 2025 17:09:12 +0530 Subject: [PATCH 2/7] test(auth): add unit tests for is_https_url helper Add test coverage for is_https_url helper to validate HTTPS scheme, non-root paths, and reject http, javascript, data schemes, and invalid inputs per SEP-991 requirements. Signed-off-by: tanish111 --- crates/rmcp/src/transport/auth.rs | 45 ++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c31932f8..a559be9e 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1313,7 +1313,50 @@ impl OAuthState { mod tests { use url::Url; - use super::AuthorizationManager; + use super::{is_https_url, AuthorizationManager}; + + // SEP-991: URL-based Client IDs + // Tests adapted from the TypeScript SDK's isHttpsUrl test suite + #[test] + fn is_https_url_returns_true_for_valid_https_url_with_path() { + assert!(is_https_url("https://example.com/client-metadata.json")); + } + + #[test] + fn is_https_url_returns_true_for_https_url_with_query_params() { + assert!(is_https_url("https://example.com/metadata?version=1")); + } + + #[test] + fn is_https_url_returns_false_for_https_url_without_path() { + assert!(!is_https_url("https://example.com")); + assert!(!is_https_url("https://example.com/")); + } + + #[test] + fn is_https_url_returns_false_for_http_url() { + assert!(!is_https_url("http://example.com/metadata")); + } + + #[test] + fn is_https_url_returns_false_for_non_url_strings() { + assert!(!is_https_url("not a url")); + } + + #[test] + fn is_https_url_returns_false_for_empty_string() { + assert!(!is_https_url("")); + } + + #[test] + fn is_https_url_returns_false_for_javascript_scheme() { + assert!(!is_https_url("javascript:alert(1)")); + } + + #[test] + fn is_https_url_returns_false_for_data_scheme() { + assert!(!is_https_url("data:text/html,")); + } #[test] fn parses_resource_metadata_parameter() { From 45af2c25118d3be55675946126b35b5fd9fdc403 Mon Sep 17 00:00:00 2001 From: tanish111 Date: Fri, 5 Dec 2025 00:25:45 +0530 Subject: [PATCH 3/7] feat(example): add CIMD OAuth server for SEP-991 testing Implements a new server example (servers_cimd_auth_streamhttp) that demonstrates CIMD (Client ID Metadata Document) support for URL-based client IDs. The server validates client_id URLs, fetches and validates client metadata documents, and provides OAuth 2.0 authorization endpoints with MCP integration for end-to-end testing. Signed-off-by: tanish111 --- examples/clients/src/auth/oauth_client.rs | 33 +- examples/servers/Cargo.toml | 5 + examples/servers/src/cimd_auth_streamhttp.rs | 501 +++++++++++++++++++ 3 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 examples/servers/src/cimd_auth_streamhttp.rs diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index bd43df91..80f0b734 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{env, net::SocketAddr, sync::Arc}; use anyhow::{Context, Result}; use axum::{ @@ -23,10 +23,11 @@ use tokio::{ }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -const MCP_SERVER_URL: &str = "http://localhost:3000/mcp"; -const MCP_REDIRECT_URI: &str = "http://localhost:8080/callback"; +const MCP_SERVER_URL: &str = "http://127.0.0.1:3000/mcp"; +const MCP_REDIRECT_URI: &str = "http://127.0.0.1:8080/callback"; const CALLBACK_PORT: u16 = 8080; const CALLBACK_HTML: &str = include_str!("callback.html"); +const CLIENT_METADATA_URL: &str = "https://raw.githubusercontent.com/tanish111/cimd-local-oauth-server/refs/heads/main/client-metadata.json"; #[derive(Clone)] struct AppState { @@ -79,7 +80,7 @@ async fn main() -> Result<()> { let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT)); tracing::info!("Starting callback server at: http://{}", addr); - tracing::warn!("Note: Callback server may not receive callbacks if redirect URI doesn't match localhost"); + tracing::warn!("Note: Callback server may not receive callbacks if redirect URI doesn't match localhost if using CIMD (SEP-991)"); // Start server in a separate task tokio::spawn(async move { @@ -91,23 +92,37 @@ async fn main() -> Result<()> { } }); - // Get server URL - let server_url = MCP_SERVER_URL.to_string(); + // Get server URL and client metadata URL from CLI (with defaults) + // + // Usage: + // cargo run --example clients_oauth_client -- + let args: Vec = env::args().collect(); + let server_url = args + .get(1) + .cloned() + .unwrap_or_else(|| MCP_SERVER_URL.to_string()); + let client_metadata_url = args + .get(2) + .cloned() + .unwrap_or_else(|| CLIENT_METADATA_URL.to_string()); + tracing::info!("Using MCP server URL: {}", server_url); + tracing::info!( + "Using CIMD (SEP-991) with client metadata URL: {}", + client_metadata_url + ); // Initialize oauth state machine let mut oauth_state = OAuthState::new(&server_url, None) .await .context("Failed to initialize oauth state machine")?; - - tracing::info!("Using CIMD (SEP-991) with client metadata URL: {}", CLIENT_METADATA_URL); // Use CIMD (SEP-991) with client metadata URL oauth_state .start_authorization_with_metadata_url( &["mcp", "profile", "email"], MCP_REDIRECT_URI, Some("Test MCP Client"), - Some(CLIENT_METADATA_URL), + Some(&client_metadata_url), ) .await .context("Failed to start authorization")?; diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index 8979a934..fde11a41 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -44,6 +44,7 @@ tower-http = { version = "0.6", features = ["cors"] } hyper = { version = "1" } hyper-util = { version = "0", features = ["server"] } tokio-util = { version = "0.7" } +url = "2.5" [dev-dependencies] tokio-stream = { version = "0.1" } @@ -96,3 +97,7 @@ path = "src/simple_auth_streamhttp.rs" [[example]] name = "servers_complex_auth_streamhttp" path = "src/complex_auth_streamhttp.rs" + +[[example]] +name = "servers_cimd_auth_streamhttp" +path = "src/cimd_auth_streamhttp.rs" diff --git a/examples/servers/src/cimd_auth_streamhttp.rs b/examples/servers/src/cimd_auth_streamhttp.rs new file mode 100644 index 00000000..5d37eb32 --- /dev/null +++ b/examples/servers/src/cimd_auth_streamhttp.rs @@ -0,0 +1,501 @@ +use std::{ + collections::HashMap, + net::SocketAddr, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use anyhow::Result; +use axum::{ + Json, Router, + extract::{Form, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use rand::{Rng, distr::Alphanumeric}; +use rmcp::transport::{ + StreamableHttpServerConfig, + streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::RwLock; +use tracing::{error, info}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use url::Url; + +// Import Counter tool for MCP service +mod common; +use common::counter::Counter; + +const BIND_ADDRESS: &str = "127.0.0.1:3000"; + +/// In-memory authorization code record +#[derive(Clone, Debug)] +struct AuthCodeRecord { + client_id: String, + redirect_uri: String, + expires_at: SystemTime, +} + +#[derive(Clone)] +struct AppState { + auth_codes: Arc>>, +} + +impl AppState { + fn new() -> Self { + Self { + auth_codes: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +fn generate_authorization_code() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +fn generate_access_token() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +/// Validate that the client_id is a URL that meets CIMD mandatory requirements. +/// Mirrors the JS validateClientIdUrl helper. +fn validate_client_id_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| "invalid_client_id: client_id must be a valid URL".to_string())?; + + // MUST have https scheme + if url.scheme() != "https" { + return Err("invalid_client_id: client_id URL MUST use https scheme".to_string()); + } + + // MUST contain a path component (cannot be empty or just "/") + let path = url.path(); + if path.is_empty() || path == "/" { + return Err("invalid_client_id: client_id URL MUST contain a path component".to_string()); + } + + // MUST NOT contain single-dot or double-dot path segments + if path.split('/').any(|s| s == "." || s == "..") { + return Err( + "invalid_client_id: client_id URL MUST NOT contain single-dot or double-dot path segments" + .to_string(), + ); + } + + // MUST NOT contain a fragment component + if url.fragment().is_some() { + return Err("invalid_client_id: client_id URL MUST NOT contain a fragment component".to_string()); + } + + // MUST NOT contain a username or password + if !url.username().is_empty() || url.password().is_some() { + return Err( + "invalid_client_id: client_id URL MUST NOT contain a username or password component".to_string(), + ); + } + + Ok(url.to_string()) +} + +/// Fetch and validate the client metadata document from the client_id URL. +/// Implements MUST / MUST NOT rules from CIMD section 4.1. +async fn fetch_and_validate_client_metadata(client_id_url: &str) -> Result { + let client = reqwest::Client::new(); + let res = client + .get(client_id_url) + .header( + reqwest::header::ACCEPT, + "application/json, application/*+json", + ) + .send() + .await + .map_err(|_| "invalid_client: failed to fetch client metadata document".to_string())?; + + if !res.status().is_success() { + return Err("invalid_client: failed to fetch client metadata document".to_string()); + } + + let json: Value = res + .json() + .await + .map_err(|_| "invalid_client: client metadata document is not valid JSON".to_string())?; + + if !json.is_object() { + return Err("invalid_client: client metadata document must be a JSON object".to_string()); + } + + // MUST contain a client_id property equal to the URL of the document + let client_id_value = json + .get("client_id") + .ok_or_else(|| "invalid_client: client metadata document MUST contain client_id".to_string())?; + if client_id_value != client_id_url { + return Err( + "invalid_client: client_id property in metadata document MUST match the document URL" + .to_string(), + ); + } + + // token_endpoint_auth_method MUST NOT be any shared secret based method + if let Some(method) = json.get("token_endpoint_auth_method") { + if let Some(method_str) = method.as_str() { + let forbidden = ["client_secret_post", "client_secret_basic", "client_secret_jwt"]; + if forbidden.contains(&method_str) + || method_str.starts_with("client_secret_") + { + return Err("invalid_client: token_endpoint_auth_method MUST NOT be a shared secret based method".to_string()); + } + } + } + + // client_secret and client_secret_expires_at MUST NOT be used + if json.get("client_secret").is_some() { + return Err("invalid_client: client_secret MUST NOT be present in client metadata".to_string()); + } + if json.get("client_secret_expires_at").is_some() { + return Err( + "invalid_client: client_secret_expires_at MUST NOT be present in client metadata".to_string(), + ); + } + + Ok(json) +} + +/// Validate redirect_uri against metadata.redirect_uris (exact match). +fn validate_redirect_uri(requested_redirect_uri: &str, metadata: &Value) -> Result<(), String> { + let redirect_uris = metadata + .get("redirect_uris") + .ok_or_else(|| "invalid_client: client metadata must include redirect_uris array".to_string())?; + + let arr = redirect_uris + .as_array() + .ok_or_else(|| "invalid_client: redirect_uris must be an array".to_string())?; + + let requested = requested_redirect_uri.to_string(); + let found = arr.iter().any(|u| u.as_str() == Some(&requested)); + + if !found { + return Err( + "invalid_request: redirect_uri MUST exactly match one of the registered redirect_uris".to_string(), + ); + } + + Ok(()) +} + +/// Minimal Authorization Server Metadata with CIMD support. +async fn oauth_metadata() -> impl IntoResponse { + let issuer = std::env::var("CIMD_ISSUER") + .unwrap_or_else(|_| format!("http://{}", BIND_ADDRESS)); + + let body = serde_json::json!({ + "issuer": issuer, + "authorization_endpoint": format!("{}/authorize", issuer), + "token_endpoint": format!("{}/token", issuer), + "client_id_metadata_document_supported": true, + }); + + Json(body) +} + +#[derive(Debug, Deserialize)] +struct AuthorizeQuery { + client_id: Option, + redirect_uri: Option, + response_type: Option, + state: Option, + scope: Option, +} + +#[derive(Debug, Deserialize)] +struct LoginForm { + username: Option, + password: Option, + // OAuth params come from hidden form fields + client_id: Option, + redirect_uri: Option, + response_type: Option, + state: Option, + scope: Option, +} + +fn render_login_form(params: &AuthorizeQuery, error: Option<&str>) -> Html { + let hidden_fields = [ + ("client_id", params.client_id.as_deref().unwrap_or_default()), + ( + "redirect_uri", + params.redirect_uri.as_deref().unwrap_or_default(), + ), + ( + "response_type", + params.response_type.as_deref().unwrap_or_default(), + ), + ("state", params.state.as_deref().unwrap_or_default()), + ("scope", params.scope.as_deref().unwrap_or_default()), + ] + .iter() + .map(|(k, v)| format!(r#""#)) + .collect::>() + .join("\n "); + + let error_html = error + .map(|e| format!(r#"
{}
"#, e)) + .unwrap_or_default(); + + let html = format!( + r#" + + + + OAuth Login - CIMD Server + + + +

OAuth Login

+ {error_html} +
+ {hidden_fields} + + + + + +
+

+ Demo credentials: admin / admin +

+ + +"# + ); + + Html(html) +} + +async fn authorize_get( + Query(params): Query, +) -> impl IntoResponse { + render_login_form(¶ms, None) +} + +async fn authorize_post( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + // Convert LoginForm (which includes OAuth params from hidden fields) to AuthorizeQuery + let params = AuthorizeQuery { + client_id: form.client_id.clone(), + redirect_uri: form.redirect_uri.clone(), + response_type: form.response_type.clone(), + state: form.state.clone(), + scope: form.scope.clone(), + }; + + match handle_authorize(&state, ¶ms, &form).await { + Ok(redirect_response) => redirect_response, + Err(error_response) => error_response, + } +} + +async fn handle_authorize( + state: &AppState, + params: &AuthorizeQuery, + form: &LoginForm, +) -> Result { + let client_id_raw = params + .client_id + .as_deref() + .ok_or_else(|| bad_request("invalid_request: client_id is required"))?; + let redirect_uri = params + .redirect_uri + .as_deref() + .ok_or_else(|| bad_request("invalid_request: redirect_uri is required"))?; + let response_type = params + .response_type + .as_deref() + .ok_or_else(|| bad_request("invalid_request: response_type is required"))?; + + if response_type != "code" { + return Err(bad_request( + "unsupported_response_type: only response_type=code is supported", + )); + } + + let client_id_url = + validate_client_id_url(client_id_raw).map_err(|e| bad_request(&e))?; + let metadata = + fetch_and_validate_client_metadata(&client_id_url).await.map_err(|e| bad_request(&e))?; + validate_redirect_uri(redirect_uri, &metadata).map_err(|e| bad_request(&e))?; + + // If this is a login POST, validate credentials + if let (Some(username), Some(password)) = (&form.username, &form.password) { + if username != "admin" || password != "admin" { + let html = render_login_form(params, Some("Invalid username or password")); + return Err(html.into_response()); + } + + // Login successful - generate authorization code and redirect + let code = generate_authorization_code(); + let expires_at = SystemTime::now() + Duration::from_secs(10 * 60); + + { + let mut codes = state.auth_codes.write().await; + codes.insert( + code.clone(), + AuthCodeRecord { + client_id: client_id_url, + redirect_uri: redirect_uri.to_string(), + expires_at, + }, + ); + } + + let mut url = + Url::parse(redirect_uri).map_err(|_| bad_request("invalid_request: redirect_uri is invalid"))?; + url.query_pairs_mut().append_pair("code", &code); + if let Some(state_param) = ¶ms.state { + url.query_pairs_mut() + .append_pair("state", state_param); + } + + Ok(Redirect::to(url.as_str()).into_response()) + } else { + // GET request without credentials: show login form + let html = render_login_form(params, None); + Err(html.into_response()) + } +} + +fn bad_request(message: &str) -> Response { + let body = serde_json::json!({ + "error": "invalid_request", + "error_description": message, + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() +} + +#[derive(Debug, Deserialize)] +struct TokenRequest { + grant_type: Option, + code: Option, +} + +async fn token( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if form.grant_type.as_deref() != Some("authorization_code") { + let body = serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "Only authorization_code is supported in this demo", + }); + return (StatusCode::BAD_REQUEST, Json(body)).into_response(); + } + + let code = match &form.code { + Some(c) => c.clone(), + None => { + let body = serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization code is required", + }); + return (StatusCode::BAD_REQUEST, Json(body)).into_response(); + } + }; + + let record_opt = { + let mut codes = state.auth_codes.write().await; + codes.remove(&code) + }; + + let record = match record_opt { + Some(r) => r, + None => { + let body = serde_json::json!({ + "error": "invalid_grant", + "error_description": "Invalid authorization code", + }); + return (StatusCode::BAD_REQUEST, Json(body)).into_response(); + } + }; + + if SystemTime::now() > record.expires_at { + let body = serde_json::json!({ + "error": "invalid_grant", + "error_description": "Authorization code has expired", + }); + return (StatusCode::BAD_REQUEST, Json(body)).into_response(); + } + + let access_token = generate_access_token(); + let body = serde_json::json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + }); + + Json(body).into_response() +} + +async fn index() -> Html<&'static str> { + Html("

CIMD OAuth + MCP Server

This server supports Client ID Metadata Documents (SEP-991) and exposes an MCP endpoint at /mcp.

") +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let state = AppState::new(); + + // Create streamable HTTP service for MCP + let mcp_service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Counter::new()), + LocalSessionManager::default().into(), + StreamableHttpServerConfig::default(), + ); + + let addr = BIND_ADDRESS.parse::()?; + + let app = Router::new() + .route("/", get(index)) + .route("/.well-known/oauth-authorization-server", get(oauth_metadata)) + .route("/authorize", get(authorize_get).post(authorize_post)) + .route("/token", post(token)) + .nest_service("/mcp", mcp_service) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(addr).await?; + info!("CIMD OAuth server listening on http://{}", addr); + + if let Err(e) = axum::serve(listener, app).await { + error!("server error: {}", e); + } + + Ok(()) +} + + From b131bb1712c9f235928b00d100f3dc6b109754e4 Mon Sep 17 00:00:00 2001 From: tanish111 Date: Fri, 5 Dec 2025 00:45:26 +0530 Subject: [PATCH 4/7] fix(oauth): add CORS headers to token endpoint Add CORS headers to token endpoint to allow cross-origin requests from browsers during OAuth authorization code exchange flow. Signed-off-by: tanish111 --- crates/rmcp/src/transport/auth.rs | 7 +- examples/clients/src/auth/oauth_client.rs | 4 +- examples/servers/src/cimd_auth_streamhttp.rs | 91 +++++++++++--------- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index a559be9e..9f54430b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -963,7 +963,10 @@ impl AuthorizationSession { ) -> Result { let metadata = auth_manager.metadata.as_ref(); let supports_url_based_client_id = metadata - .and_then(|m| m.additional_fields.get("client_id_metadata_document_supported")) + .and_then(|m| { + m.additional_fields + .get("client_id_metadata_document_supported") + }) .and_then(|v| v.as_bool()) .unwrap_or(false); @@ -1313,7 +1316,7 @@ impl OAuthState { mod tests { use url::Url; - use super::{is_https_url, AuthorizationManager}; + use super::{AuthorizationManager, is_https_url}; // SEP-991: URL-based Client IDs // Tests adapted from the TypeScript SDK's isHttpsUrl test suite diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 80f0b734..83182cfd 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -80,7 +80,9 @@ async fn main() -> Result<()> { let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT)); tracing::info!("Starting callback server at: http://{}", addr); - tracing::warn!("Note: Callback server may not receive callbacks if redirect URI doesn't match localhost if using CIMD (SEP-991)"); + tracing::warn!( + "Note: Callback server may not receive callbacks if redirect URI doesn't match localhost if using CIMD (SEP-991)" + ); // Start server in a separate task tokio::spawn(async move { diff --git a/examples/servers/src/cimd_auth_streamhttp.rs b/examples/servers/src/cimd_auth_streamhttp.rs index 5d37eb32..73eab5e6 100644 --- a/examples/servers/src/cimd_auth_streamhttp.rs +++ b/examples/servers/src/cimd_auth_streamhttp.rs @@ -21,6 +21,7 @@ use rmcp::transport::{ use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::RwLock; +use tower_http::cors::{Any, CorsLayer}; use tracing::{error, info}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use url::Url; @@ -71,7 +72,8 @@ fn generate_access_token() -> String { /// Validate that the client_id is a URL that meets CIMD mandatory requirements. /// Mirrors the JS validateClientIdUrl helper. fn validate_client_id_url(raw: &str) -> Result { - let url = Url::parse(raw).map_err(|_| "invalid_client_id: client_id must be a valid URL".to_string())?; + let url = Url::parse(raw) + .map_err(|_| "invalid_client_id: client_id must be a valid URL".to_string())?; // MUST have https scheme if url.scheme() != "https" { @@ -94,13 +96,16 @@ fn validate_client_id_url(raw: &str) -> Result { // MUST NOT contain a fragment component if url.fragment().is_some() { - return Err("invalid_client_id: client_id URL MUST NOT contain a fragment component".to_string()); + return Err( + "invalid_client_id: client_id URL MUST NOT contain a fragment component".to_string(), + ); } // MUST NOT contain a username or password if !url.username().is_empty() || url.password().is_some() { return Err( - "invalid_client_id: client_id URL MUST NOT contain a username or password component".to_string(), + "invalid_client_id: client_id URL MUST NOT contain a username or password component" + .to_string(), ); } @@ -135,9 +140,9 @@ async fn fetch_and_validate_client_metadata(client_id_url: &str) -> Result Result Result Result Result<(), String> { - let redirect_uris = metadata - .get("redirect_uris") - .ok_or_else(|| "invalid_client: client metadata must include redirect_uris array".to_string())?; + let redirect_uris = metadata.get("redirect_uris").ok_or_else(|| { + "invalid_client: client metadata must include redirect_uris array".to_string() + })?; let arr = redirect_uris .as_array() @@ -185,7 +195,8 @@ fn validate_redirect_uri(requested_redirect_uri: &str, metadata: &Value) -> Resu if !found { return Err( - "invalid_request: redirect_uri MUST exactly match one of the registered redirect_uris".to_string(), + "invalid_request: redirect_uri MUST exactly match one of the registered redirect_uris" + .to_string(), ); } @@ -194,8 +205,8 @@ fn validate_redirect_uri(requested_redirect_uri: &str, metadata: &Value) -> Resu /// Minimal Authorization Server Metadata with CIMD support. async fn oauth_metadata() -> impl IntoResponse { - let issuer = std::env::var("CIMD_ISSUER") - .unwrap_or_else(|_| format!("http://{}", BIND_ADDRESS)); + let issuer = + std::env::var("CIMD_ISSUER").unwrap_or_else(|_| format!("http://{}", BIND_ADDRESS)); let body = serde_json::json!({ "issuer": issuer, @@ -288,9 +299,7 @@ fn render_login_form(params: &AuthorizeQuery, error: Option<&str>) -> Html, -) -> impl IntoResponse { +async fn authorize_get(Query(params): Query) -> impl IntoResponse { render_login_form(¶ms, None) } @@ -306,7 +315,7 @@ async fn authorize_post( state: form.state.clone(), scope: form.scope.clone(), }; - + match handle_authorize(&state, ¶ms, &form).await { Ok(redirect_response) => redirect_response, Err(error_response) => error_response, @@ -337,10 +346,10 @@ async fn handle_authorize( )); } - let client_id_url = - validate_client_id_url(client_id_raw).map_err(|e| bad_request(&e))?; - let metadata = - fetch_and_validate_client_metadata(&client_id_url).await.map_err(|e| bad_request(&e))?; + let client_id_url = validate_client_id_url(client_id_raw).map_err(|e| bad_request(&e))?; + let metadata = fetch_and_validate_client_metadata(&client_id_url) + .await + .map_err(|e| bad_request(&e))?; validate_redirect_uri(redirect_uri, &metadata).map_err(|e| bad_request(&e))?; // If this is a login POST, validate credentials @@ -366,12 +375,11 @@ async fn handle_authorize( ); } - let mut url = - Url::parse(redirect_uri).map_err(|_| bad_request("invalid_request: redirect_uri is invalid"))?; + let mut url = Url::parse(redirect_uri) + .map_err(|_| bad_request("invalid_request: redirect_uri is invalid"))?; url.query_pairs_mut().append_pair("code", &code); if let Some(state_param) = ¶ms.state { - url.query_pairs_mut() - .append_pair("state", state_param); + url.query_pairs_mut().append_pair("state", state_param); } Ok(Redirect::to(url.as_str()).into_response()) @@ -396,10 +404,7 @@ struct TokenRequest { code: Option, } -async fn token( - State(state): State, - Form(form): Form, -) -> impl IntoResponse { +async fn token(State(state): State, Form(form): Form) -> impl IntoResponse { if form.grant_type.as_deref() != Some("authorization_code") { let body = serde_json::json!({ "error": "unsupported_grant_type", @@ -454,7 +459,9 @@ async fn token( } async fn index() -> Html<&'static str> { - Html("

CIMD OAuth + MCP Server

This server supports Client ID Metadata Documents (SEP-991) and exposes an MCP endpoint at /mcp.

") + Html( + "

CIMD OAuth + MCP Server

This server supports Client ID Metadata Documents (SEP-991) and exposes an MCP endpoint at /mcp.

", + ) } #[tokio::main] @@ -480,11 +487,19 @@ async fn main() -> Result<()> { let addr = BIND_ADDRESS.parse::()?; + let cors_layer = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + let app = Router::new() .route("/", get(index)) - .route("/.well-known/oauth-authorization-server", get(oauth_metadata)) + .route( + "/.well-known/oauth-authorization-server", + get(oauth_metadata), + ) .route("/authorize", get(authorize_get).post(authorize_post)) - .route("/token", post(token)) + .route("/token", post(token).layer(cors_layer.clone())) .nest_service("/mcp", mcp_service) .with_state(state); @@ -497,5 +512,3 @@ async fn main() -> Result<()> { Ok(()) } - - From 463b73a1d953a145ecab9abaacb710a3a1564f77 Mon Sep 17 00:00:00 2001 From: tanish111 Date: Tue, 9 Dec 2025 09:09:23 +0530 Subject: [PATCH 5/7] refactor: improve is_https_url function and consolidate tests - Improve is_https_url function formatting and readability - Merge all test cases into single test_is_https_url_scenarios function - Add missing test case for "https://" URL Signed-off-by: tanish111 --- crates/rmcp/src/transport/auth.rs | 46 +++++++++---------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 9f54430b..7a792bf9 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -242,10 +242,10 @@ struct AuthorizationState { /// SEP-991: URL-based Client IDs /// Validate that the client_id is a valid URL with https scheme and non-root pathname fn is_https_url(value: &str) -> bool { - match Url::parse(value) { - Ok(url) => url.scheme() == "https" && url.path() != "/", - Err(_) => false, - } + Url::parse(value) + .ok() + .map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some()) + .unwrap_or(false) } impl AuthorizationManager { @@ -1321,43 +1321,23 @@ mod tests { // SEP-991: URL-based Client IDs // Tests adapted from the TypeScript SDK's isHttpsUrl test suite #[test] - fn is_https_url_returns_true_for_valid_https_url_with_path() { + fn test_is_https_url_scenarios() { + // Returns true for valid https url with path assert!(is_https_url("https://example.com/client-metadata.json")); - } - - #[test] - fn is_https_url_returns_true_for_https_url_with_query_params() { + // Returns true for https url with query params assert!(is_https_url("https://example.com/metadata?version=1")); - } - - #[test] - fn is_https_url_returns_false_for_https_url_without_path() { + // Returns false for https url without path assert!(!is_https_url("https://example.com")); assert!(!is_https_url("https://example.com/")); - } - - #[test] - fn is_https_url_returns_false_for_http_url() { + // Returns false for http url assert!(!is_https_url("http://example.com/metadata")); - } - - #[test] - fn is_https_url_returns_false_for_non_url_strings() { + // Returns false for non-url strings assert!(!is_https_url("not a url")); - } - - #[test] - fn is_https_url_returns_false_for_empty_string() { + // Returns false for empty string assert!(!is_https_url("")); - } - - #[test] - fn is_https_url_returns_false_for_javascript_scheme() { + // Returns false for javascript scheme assert!(!is_https_url("javascript:alert(1)")); - } - - #[test] - fn is_https_url_returns_false_for_data_scheme() { + // Returns false for data scheme assert!(!is_https_url("data:text/html,")); } From 51f0209dd6b5ac32e02c2cf9a34dd10d08f2ea6c Mon Sep 17 00:00:00 2001 From: tanish111 Date: Tue, 9 Dec 2025 23:51:24 +0530 Subject: [PATCH 6/7] refactor: use map_err instead of match for error handling in auth.rs Replace the verbose match statement with map_err for more idiomatic Signed-off-by: tanish111 --- crates/rmcp/src/transport/auth.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 53b72681..412d585a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -987,18 +987,12 @@ impl AuthorizationSession { } } else { // Fallback to dynamic registration - match auth_manager + auth_manager .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) .await - { - Ok(config) => config, - Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "Dynamic registration failed: {}", - e - ))); - } - } + .map_err(|e| { + AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) + })? } } else { // Fallback to dynamic registration From a8a5e920c338ac1ce4d1054864bd4bd7b1ed47de Mon Sep 17 00:00:00 2001 From: tanish111 Date: Wed, 10 Dec 2025 07:40:55 +0530 Subject: [PATCH 7/7] feat: add client-metadata.json Add client metadata file for SEP-991 CIMD authentication support Signed-off-by: tanish111 --- client-metadata.json | 7 +++++++ examples/clients/src/auth/oauth_client.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 client-metadata.json diff --git a/client-metadata.json b/client-metadata.json new file mode 100644 index 00000000..e1b78b9f --- /dev/null +++ b/client-metadata.json @@ -0,0 +1,7 @@ +{ + "client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json", + "redirect_uris": ["http://localhost:4000/callback"], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" +} diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 83182cfd..9b131652 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -27,7 +27,7 @@ const MCP_SERVER_URL: &str = "http://127.0.0.1:3000/mcp"; const MCP_REDIRECT_URI: &str = "http://127.0.0.1:8080/callback"; const CALLBACK_PORT: u16 = 8080; const CALLBACK_HTML: &str = include_str!("callback.html"); -const CLIENT_METADATA_URL: &str = "https://raw.githubusercontent.com/tanish111/cimd-local-oauth-server/refs/heads/main/client-metadata.json"; +const CLIENT_METADATA_URL: &str = "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json"; #[derive(Clone)] struct AppState {