Skip to content

Commit f20ed20

Browse files
authored
Add SEP-991 (CIMD) support for URL-based client IDs (#570)
* feat(auth): add cimd support for SEP-991 add cimd support for url-based client ids Signed-off-by: tanish111 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * feat: add client-metadata.json Add client metadata file for SEP-991 CIMD authentication support Signed-off-by: tanish111 <[email protected]> --------- Signed-off-by: tanish111 <[email protected]>
1 parent e3fd384 commit f20ed20

File tree

5 files changed

+656
-30
lines changed

5 files changed

+656
-30
lines changed

client-metadata.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json",
3+
"redirect_uris": ["http://localhost:4000/callback"],
4+
"grant_types": ["authorization_code"],
5+
"response_types": ["code"],
6+
"token_endpoint_auth_method": "none"
7+
}

crates/rmcp/src/transport/auth.rs

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ struct AuthorizationState {
240240
csrf_token: CsrfToken,
241241
}
242242

243+
/// SEP-991: URL-based Client IDs
244+
/// Validate that the client_id is a valid URL with https scheme and non-root pathname
245+
fn is_https_url(value: &str) -> bool {
246+
Url::parse(value)
247+
.ok()
248+
.map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some())
249+
.unwrap_or(false)
250+
}
251+
243252
impl AuthorizationManager {
244253
fn well_known_paths(base_path: &str, resource: &str) -> Vec<String> {
245254
let trimmed = base_path.trim_start_matches('/').trim_end_matches('/');
@@ -968,30 +977,57 @@ impl AuthorizationSession {
968977
scopes: &[&str],
969978
redirect_uri: &str,
970979
client_name: Option<&str>,
980+
client_metadata_url: Option<&str>,
971981
) -> Result<Self, AuthError> {
972-
// Default client config
973-
let config = OAuthClientConfig {
974-
client_id: "mcp-client".to_string(),
975-
client_secret: None,
976-
scopes: scopes.iter().map(|s| s.to_string()).collect(),
977-
redirect_uri: redirect_uri.to_string(),
978-
};
979-
980-
// try to dynamic register client
981-
let config = match auth_manager
982-
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
983-
.await
984-
{
985-
Ok(config) => config,
986-
Err(e) => {
987-
warn!(
988-
"Dynamic registration failed: {}, fallback to default config",
989-
e
990-
);
991-
// fallback to default config
992-
config
982+
let metadata = auth_manager.metadata.as_ref();
983+
let supports_url_based_client_id = metadata
984+
.and_then(|m| {
985+
m.additional_fields
986+
.get("client_id_metadata_document_supported")
987+
})
988+
.and_then(|v| v.as_bool())
989+
.unwrap_or(false);
990+
991+
let config = if supports_url_based_client_id {
992+
if let Some(client_metadata_url) = client_metadata_url {
993+
if !is_https_url(client_metadata_url) {
994+
return Err(AuthError::RegistrationFailed(format!(
995+
"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}",
996+
client_metadata_url
997+
)));
998+
}
999+
// SEP-991: URL-based Client IDs - use URL as client_id directly
1000+
OAuthClientConfig {
1001+
client_id: client_metadata_url.to_string(),
1002+
client_secret: None,
1003+
scopes: scopes.iter().map(|s| s.to_string()).collect(),
1004+
redirect_uri: redirect_uri.to_string(),
1005+
}
1006+
} else {
1007+
// Fallback to dynamic registration
1008+
auth_manager
1009+
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
1010+
.await
1011+
.map_err(|e| {
1012+
AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e))
1013+
})?
1014+
}
1015+
} else {
1016+
// Fallback to dynamic registration
1017+
match auth_manager
1018+
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
1019+
.await
1020+
{
1021+
Ok(config) => config,
1022+
Err(e) => {
1023+
return Err(AuthError::RegistrationFailed(format!(
1024+
"Dynamic registration failed: {}",
1025+
e
1026+
)));
1027+
}
9931028
}
9941029
};
1030+
9951031
// reset client config
9961032
auth_manager.configure_client(config)?;
9971033
let auth_url = auth_manager.get_authorization_url(scopes).await?;
@@ -1143,6 +1179,18 @@ impl OAuthState {
11431179
scopes: &[&str],
11441180
redirect_uri: &str,
11451181
client_name: Option<&str>,
1182+
) -> Result<(), AuthError> {
1183+
self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None)
1184+
.await
1185+
}
1186+
1187+
/// start authorization with optional client metadata URL (SEP-991)
1188+
pub async fn start_authorization_with_metadata_url(
1189+
&mut self,
1190+
scopes: &[&str],
1191+
redirect_uri: &str,
1192+
client_name: Option<&str>,
1193+
client_metadata_url: Option<&str>,
11461194
) -> Result<(), AuthError> {
11471195
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(
11481196
self,
@@ -1152,8 +1200,14 @@ impl OAuthState {
11521200
let metadata = manager.discover_metadata().await?;
11531201
manager.metadata = Some(metadata);
11541202
debug!("start session");
1155-
let session =
1156-
AuthorizationSession::new(manager, scopes, redirect_uri, client_name).await?;
1203+
let session = AuthorizationSession::new(
1204+
manager,
1205+
scopes,
1206+
redirect_uri,
1207+
client_name,
1208+
client_metadata_url,
1209+
)
1210+
.await?;
11571211
*self = OAuthState::Session(session);
11581212
Ok(())
11591213
} else {
@@ -1274,7 +1328,31 @@ impl OAuthState {
12741328
mod tests {
12751329
use url::Url;
12761330

1277-
use super::AuthorizationManager;
1331+
use super::{AuthorizationManager, is_https_url};
1332+
1333+
// SEP-991: URL-based Client IDs
1334+
// Tests adapted from the TypeScript SDK's isHttpsUrl test suite
1335+
#[test]
1336+
fn test_is_https_url_scenarios() {
1337+
// Returns true for valid https url with path
1338+
assert!(is_https_url("https://example.com/client-metadata.json"));
1339+
// Returns true for https url with query params
1340+
assert!(is_https_url("https://example.com/metadata?version=1"));
1341+
// Returns false for https url without path
1342+
assert!(!is_https_url("https://example.com"));
1343+
assert!(!is_https_url("https://example.com/"));
1344+
assert!(!is_https_url("https://"));
1345+
// Returns false for http url
1346+
assert!(!is_https_url("http://example.com/metadata"));
1347+
// Returns false for non-url strings
1348+
assert!(!is_https_url("not a url"));
1349+
// Returns false for empty string
1350+
assert!(!is_https_url(""));
1351+
// Returns false for javascript scheme
1352+
assert!(!is_https_url("javascript:alert(1)"));
1353+
// Returns false for data scheme
1354+
assert!(!is_https_url("data:text/html,<script>alert(1)</script>"));
1355+
}
12781356

12791357
#[test]
12801358
fn parses_resource_metadata_parameter() {

examples/clients/src/auth/oauth_client.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{net::SocketAddr, sync::Arc};
1+
use std::{env, net::SocketAddr, sync::Arc};
22

33
use anyhow::{Context, Result};
44
use axum::{
@@ -23,10 +23,11 @@ use tokio::{
2323
};
2424
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
2525

26-
const MCP_SERVER_URL: &str = "http://localhost:3000/mcp";
27-
const MCP_REDIRECT_URI: &str = "http://localhost:8080/callback";
26+
const MCP_SERVER_URL: &str = "http://127.0.0.1:3000/mcp";
27+
const MCP_REDIRECT_URI: &str = "http://127.0.0.1:8080/callback";
2828
const CALLBACK_PORT: u16 = 8080;
2929
const CALLBACK_HTML: &str = include_str!("callback.html");
30+
const CLIENT_METADATA_URL: &str = "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json";
3031

3132
#[derive(Clone)]
3233
struct AppState {
@@ -79,6 +80,9 @@ async fn main() -> Result<()> {
7980

8081
let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT));
8182
tracing::info!("Starting callback server at: http://{}", addr);
83+
tracing::warn!(
84+
"Note: Callback server may not receive callbacks if redirect URI doesn't match localhost if using CIMD (SEP-991)"
85+
);
8286

8387
// Start server in a separate task
8488
tokio::spawn(async move {
@@ -90,19 +94,37 @@ async fn main() -> Result<()> {
9094
}
9195
});
9296

93-
// Get server URL
94-
let server_url = MCP_SERVER_URL.to_string();
97+
// Get server URL and client metadata URL from CLI (with defaults)
98+
//
99+
// Usage:
100+
// cargo run --example clients_oauth_client -- <server_url> <client_metadata_url>
101+
let args: Vec<String> = env::args().collect();
102+
let server_url = args
103+
.get(1)
104+
.cloned()
105+
.unwrap_or_else(|| MCP_SERVER_URL.to_string());
106+
let client_metadata_url = args
107+
.get(2)
108+
.cloned()
109+
.unwrap_or_else(|| CLIENT_METADATA_URL.to_string());
110+
95111
tracing::info!("Using MCP server URL: {}", server_url);
112+
tracing::info!(
113+
"Using CIMD (SEP-991) with client metadata URL: {}",
114+
client_metadata_url
115+
);
96116

97117
// Initialize oauth state machine
98118
let mut oauth_state = OAuthState::new(&server_url, None)
99119
.await
100120
.context("Failed to initialize oauth state machine")?;
121+
// Use CIMD (SEP-991) with client metadata URL
101122
oauth_state
102-
.start_authorization(
123+
.start_authorization_with_metadata_url(
103124
&["mcp", "profile", "email"],
104125
MCP_REDIRECT_URI,
105126
Some("Test MCP Client"),
127+
Some(&client_metadata_url),
106128
)
107129
.await
108130
.context("Failed to start authorization")?;

examples/servers/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ tower-http = { version = "0.6", features = ["cors"] }
4444
hyper = { version = "1" }
4545
hyper-util = { version = "0", features = ["server"] }
4646
tokio-util = { version = "0.7" }
47+
url = "2.5"
4748

4849
[dev-dependencies]
4950
tokio-stream = { version = "0.1" }
@@ -97,6 +98,10 @@ path = "src/simple_auth_streamhttp.rs"
9798
name = "servers_complex_auth_streamhttp"
9899
path = "src/complex_auth_streamhttp.rs"
99100

101+
[[example]]
102+
name = "servers_cimd_auth_streamhttp"
103+
path = "src/cimd_auth_streamhttp.rs"
104+
100105
[[example]]
101106
name = "servers_calculator_stdio"
102107
path = "src/calculator_stdio.rs"

0 commit comments

Comments
 (0)