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
7 changes: 7 additions & 0 deletions client-metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
126 changes: 102 additions & 24 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Url::parse(value)
.ok()
.map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some())
.unwrap_or(false)
}

impl AuthorizationManager {
fn well_known_paths(base_path: &str, resource: &str) -> Vec<String> {
let trimmed = base_path.trim_start_matches('/').trim_end_matches('/');
Expand Down Expand Up @@ -950,30 +959,57 @@ impl AuthorizationSession {
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
client_metadata_url: Option<&str>,
) -> Result<Self, AuthError> {
// 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
auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.await
.map_err(|e| {
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?;
Expand Down Expand Up @@ -1125,6 +1161,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,
Expand All @@ -1134,8 +1182,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 {
Expand Down Expand Up @@ -1256,7 +1310,31 @@ impl OAuthState {
mod tests {
use url::Url;

use super::AuthorizationManager;
use super::{AuthorizationManager, is_https_url};

// SEP-991: URL-based Client IDs
// Tests adapted from the TypeScript SDK's isHttpsUrl test suite
#[test]
fn test_is_https_url_scenarios() {
// Returns true for valid https url with path
assert!(is_https_url("https://example.com/client-metadata.json"));
// Returns true for https url with query params
assert!(is_https_url("https://example.com/metadata?version=1"));
// Returns false for https url without path
assert!(!is_https_url("https://example.com"));
assert!(!is_https_url("https://example.com/"));
assert!(!is_https_url("https://"));
// Returns false for http url
assert!(!is_https_url("http://example.com/metadata"));
// Returns false for non-url strings
assert!(!is_https_url("not a url"));
// Returns false for empty string
assert!(!is_https_url(""));
// Returns false for javascript scheme
assert!(!is_https_url("javascript:alert(1)"));
// Returns false for data scheme
assert!(!is_https_url("data:text/html,<script>alert(1)</script>"));
}

#[test]
fn parses_resource_metadata_parameter() {
Expand Down
34 changes: 28 additions & 6 deletions examples/clients/src/auth/oauth_client.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{net::SocketAddr, sync::Arc};
use std::{env, net::SocketAddr, sync::Arc};

use anyhow::{Context, Result};
use axum::{
Expand All @@ -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/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json";

#[derive(Clone)]
struct AppState {
Expand Down Expand Up @@ -79,6 +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)"
);

// Start server in a separate task
tokio::spawn(async move {
Expand All @@ -90,19 +94,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 -- <server_url> <client_metadata_url>
let args: Vec<String> = 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")?;
// 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")?;
Expand Down
5 changes: 5 additions & 0 deletions examples/servers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -97,6 +98,10 @@ path = "src/simple_auth_streamhttp.rs"
name = "servers_complex_auth_streamhttp"
path = "src/complex_auth_streamhttp.rs"

[[example]]
name = "servers_cimd_auth_streamhttp"
path = "src/cimd_auth_streamhttp.rs"

[[example]]
name = "servers_calculator_stdio"
path = "src/calculator_stdio.rs"
Loading