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 <tanishdesai37@gmail.com>

* 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 <tanishdesai37@gmail.com>

* 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 <tanishdesai37@gmail.com>

* 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 <tanishdesai37@gmail.com>

* 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 <tanishdesai37@gmail.com>

* 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 <tanishdesai37@gmail.com>

* feat: add client-metadata.json

Add client metadata file for SEP-991 CIMD
authentication support

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

---------

Signed-off-by: tanish111 <tanishdesai37@gmail.com>
This commit is contained in:
Tanish Desai 2025-12-10 19:25:38 +05:30 committed by GitHub
parent e3fd384a45
commit f20ed202af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 655 additions and 29 deletions

7
client-metadata.json Normal file
View file

@ -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"
}

View file

@ -240,6 +240,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('/');
@ -968,30 +977,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(),
};
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);
// 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 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?;
@ -1143,6 +1179,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,
@ -1152,8 +1200,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 {
@ -1274,7 +1328,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() {

View file

@ -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/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json";
#[derive(Clone)]
struct AppState {
@ -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 {
@ -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")?;

View file

@ -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" }
@ -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"

View file

@ -0,0 +1,514 @@
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 tower_http::cors::{Any, CorsLayer};
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<RwLock<HashMap<String, AuthCodeRecord>>>,
}
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<String, 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" {
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<Value, String> {
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<String>,
redirect_uri: Option<String>,
response_type: Option<String>,
state: Option<String>,
scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LoginForm {
username: Option<String>,
password: Option<String>,
// OAuth params come from hidden form fields
client_id: Option<String>,
redirect_uri: Option<String>,
response_type: Option<String>,
state: Option<String>,
scope: Option<String>,
}
fn render_login_form(params: &AuthorizeQuery, error: Option<&str>) -> Html<String> {
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#"<input type="hidden" name="{k}" value="{v}">"#))
.collect::<Vec<_>>()
.join("\n ");
let error_html = error
.map(|e| format!(r#"<div class="error">{}</div>"#, e))
.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OAuth Login - CIMD Server</title>
<style>
body {{ font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }}
form {{ border: 1px solid #ddd; padding: 20px; border-radius: 8px; }}
input {{ width: 100%; padding: 8px; margin: 8px 0; box-sizing: border-box; }}
button {{ background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; width: 100%; }}
button:hover {{ background: #0056b3; }}
.error {{ color: red; margin-bottom: 10px; }}
</style>
</head>
<body>
<h1>OAuth Login</h1>
{error_html}
<form method="POST" action="/authorize">
{hidden_fields}
<label>Username:</label>
<input type="text" name="username" required autofocus>
<label>Password:</label>
<input type="password" name="password" required>
<button type="submit">Login</button>
</form>
<p style="font-size: 0.9em; color: #666; margin-top: 20px;">
Demo credentials: <strong>admin</strong> / <strong>admin</strong>
</p>
</body>
</html>
"#
);
Html(html)
}
async fn authorize_get(Query(params): Query<AuthorizeQuery>) -> impl IntoResponse {
render_login_form(&params, None)
}
async fn authorize_post(
State(state): State<AppState>,
Form(form): Form<LoginForm>,
) -> 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, &params, &form).await {
Ok(redirect_response) => redirect_response,
Err(error_response) => error_response,
}
}
async fn handle_authorize(
state: &AppState,
params: &AuthorizeQuery,
form: &LoginForm,
) -> Result<Response, Response> {
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) = &params.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<String>,
code: Option<String>,
}
async fn token(State(state): State<AppState>, Form(form): Form<TokenRequest>) -> 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(
"<html><body><h1>CIMD OAuth + MCP Server</h1><p>This server supports Client ID Metadata Documents (SEP-991) and exposes an MCP endpoint at <code>/mcp</code>.</p></body></html>",
)
}
#[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<Counter, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let addr = BIND_ADDRESS.parse::<SocketAddr>()?;
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("/authorize", get(authorize_get).post(authorize_post))
.route("/token", post(token).layer(cors_layer.clone()))
.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(())
}