') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Add SEP-991 (CIMD) support for URL-based client IDs by tanish111 · Pull Request #570 · modelcontextprotocol/rust-sdk · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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)
}
Comment thread
tanish111 marked this conversation as resolved.

impl AuthorizationManager {
fn well_known_paths(base_path: &str, resource: &str) -> Vec<String> {
let trimmed = base_path.trim_start_matches('/').trim_end_matches('/');
Expand DownExpand 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 DownExpand Up@@ -1125,6 +1161,18 @@ impl OAuthState {
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
) -> Result<(), AuthError> {
Comment thread
tanish111 marked this conversation as resolved.
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 DownExpand 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
Comment thread
tanish111 marked this conversation as resolved.
// 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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