rust-sdk/examples/servers/src/complex_auth_streamhttp.rs
Dale Seo 6fa40536de
fix: resolve clippy warnings across workspace (#1195)
* fix: resolve clippy warnings across workspace

* ci: also run clippy on rmcp with all features except local

--all-features enables the local feature, which cfg-gates out
tower.rs and most of the test suite, so the existing clippy step
never actually lints them.
2026-09-08 19:05:29 -04:00

691 lines
21 KiB
Rust

use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use anyhow::Result;
use askama::Template;
use axum::{
Json, Router,
body::Body,
extract::{Form, Query, State},
http::{Request, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use rand::{RngExt, distr::Alphanumeric};
use rmcp::transport::{
StreamableHttpServerConfig,
auth::{AuthorizationMetadata, ClientRegistrationResponse, OAuthClientConfig},
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::{debug, error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
// Import Counter tool for MCP service
mod common;
use common::counter::Counter;
const BIND_ADDRESS: &str = "127.0.0.1:3000";
const INDEX_HTML: &str = include_str!("html/mcp_oauth_index.html");
// Local registration request - only uses fields needed for this demo
#[derive(Debug, Deserialize)]
struct LocalClientRegistrationRequest {
client_name: String,
redirect_uris: Vec<String>,
}
// A easy way to manage MCP OAuth Store for managing tokens and sessions
#[derive(Clone, Debug)]
struct McpOAuthStore {
clients: Arc<RwLock<HashMap<String, OAuthClientConfig>>>,
auth_sessions: Arc<RwLock<HashMap<String, AuthSession>>>,
access_tokens: Arc<RwLock<HashMap<String, McpAccessToken>>>,
}
impl McpOAuthStore {
fn new() -> Self {
let mut clients = HashMap::new();
clients.insert(
"mcp-client".to_string(),
OAuthClientConfig::new("mcp-client", "http://localhost:8080/callback")
.with_client_secret("mcp-client-secret")
.with_scopes(vec!["profile".to_string(), "email".to_string()]),
);
Self {
clients: Arc::new(RwLock::new(clients)),
auth_sessions: Arc::new(RwLock::new(HashMap::new())),
access_tokens: Arc::new(RwLock::new(HashMap::new())),
}
}
async fn validate_client(
&self,
client_id: &str,
redirect_uri: &str,
) -> Option<OAuthClientConfig> {
let clients = self.clients.read().await;
if let Some(client) = clients.get(client_id)
&& client.redirect_uri == redirect_uri
{
return Some(client.clone());
}
None
}
async fn create_auth_session(
&self,
client_id: String,
scope: Option<String>,
state: Option<String>,
session_id: String,
) -> String {
let session = AuthSession {
client_id,
scope,
_state: state,
_created_at: chrono::Utc::now(),
auth_token: None,
};
self.auth_sessions
.write()
.await
.insert(session_id.clone(), session);
session_id
}
async fn update_auth_session_token(
&self,
session_id: &str,
token: AuthToken,
) -> Result<(), String> {
let mut sessions = self.auth_sessions.write().await;
if let Some(session) = sessions.get_mut(session_id) {
session.auth_token = Some(token);
Ok(())
} else {
Err("Session not found".to_string())
}
}
async fn create_mcp_token(&self, session_id: &str) -> Result<McpAccessToken, String> {
let sessions = self.auth_sessions.read().await;
if let Some(session) = sessions.get(session_id) {
if let Some(auth_token) = &session.auth_token {
let access_token = format!("mcp-token-{}", Uuid::new_v4());
let token = McpAccessToken {
access_token: access_token.clone(),
token_type: "Bearer".to_string(),
expires_in: 3600,
refresh_token: format!("mcp-refresh-{}", Uuid::new_v4()),
scope: session.scope.clone(),
auth_token: auth_token.clone(),
client_id: session.client_id.clone(),
};
self.access_tokens
.write()
.await
.insert(access_token.clone(), token.clone());
Ok(token)
} else {
Err("No third-party token available for session".to_string())
}
} else {
Err("Session not found".to_string())
}
}
async fn validate_token(&self, token: &str) -> Option<McpAccessToken> {
self.access_tokens.read().await.get(token).cloned()
}
}
// a simple session record for auth session
#[derive(Clone, Debug)]
struct AuthSession {
client_id: String,
scope: Option<String>,
_state: Option<String>,
_created_at: chrono::DateTime<chrono::Utc>,
auth_token: Option<AuthToken>,
}
// a simple token record for auth token
// not used oauth2 token for avoid include oauth2 crate in this example
#[derive(Clone, Debug, Serialize, Deserialize)]
struct AuthToken {
access_token: String,
token_type: String,
expires_in: u64,
refresh_token: String,
scope: Option<String>,
}
// a simple token record for mcp token ,
// not used oauth2 token for avoid include oauth2 crate in this example
#[derive(Clone, Debug, Serialize)]
struct McpAccessToken {
access_token: String,
token_type: String,
expires_in: u64,
refresh_token: String,
scope: Option<String>,
auth_token: AuthToken,
client_id: String,
}
#[derive(Debug, Deserialize)]
struct AuthorizeQuery {
#[allow(dead_code)]
response_type: String,
client_id: String,
redirect_uri: String,
scope: Option<String>,
state: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TokenRequest {
grant_type: String,
#[serde(default)]
code: String,
#[serde(default)]
client_id: String,
#[serde(default)]
client_secret: String,
#[serde(default)]
redirect_uri: String,
#[serde(default)]
code_verifier: Option<String>,
#[serde(default)]
refresh_token: String,
}
fn generate_random_string(length: usize) -> String {
rand::rng()
.sample_iter(&Alphanumeric)
.take(length)
.map(char::from)
.collect()
}
// Root path handler
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
#[derive(Template)]
#[template(path = "mcp_oauth_authorize.html")]
struct OAuthAuthorizeTemplate {
client_id: String,
redirect_uri: String,
scope: String,
state: String,
scopes: String,
}
// Initial OAuth authorize endpoint
async fn oauth_authorize(
Query(params): Query<AuthorizeQuery>,
State(state): State<Arc<McpOAuthStore>>,
) -> impl IntoResponse {
debug!("doing oauth_authorize");
if let Some(_client) = state
.validate_client(&params.client_id, &params.redirect_uri)
.await
{
let template = OAuthAuthorizeTemplate {
client_id: params.client_id,
redirect_uri: params.redirect_uri,
scope: params.scope.clone().unwrap_or_default(),
state: params.state.clone().unwrap_or_default(),
scopes: params
.scope
.clone()
.unwrap_or_else(|| "Basic scope".to_string()),
};
Html(template.render().unwrap()).into_response()
} else {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "invalid client id or redirect uri"
})),
)
.into_response()
}
}
// handle approval of authorization
#[derive(Debug, Deserialize)]
struct ApprovalForm {
client_id: String,
redirect_uri: String,
scope: String,
state: String,
approved: String,
}
async fn oauth_approve(
State(state): State<Arc<McpOAuthStore>>,
Form(form): Form<ApprovalForm>,
) -> impl IntoResponse {
if form.approved != "true" {
// user rejected the authorization request
let redirect_url = format!(
"{}?error=access_denied&error_description={}{}",
form.redirect_uri,
"user rejected the authorization request",
if form.state.is_empty() {
"".to_string()
} else {
format!("&state={}", form.state)
}
);
return Redirect::to(&redirect_url).into_response();
}
// user approved the authorization request, generate authorization code
let session_id = Uuid::new_v4().to_string();
let auth_code = format!("mcp-code-{}", session_id);
// create new session record authorization information
let session_id = state
.create_auth_session(
form.client_id.clone(),
Some(form.scope.clone()),
Some(form.state.clone()),
session_id.clone(),
)
.await;
// create token
let created_token = AuthToken {
access_token: format!("tp-token-{}", Uuid::new_v4()),
token_type: "Bearer".to_string(),
expires_in: 3600,
refresh_token: format!("tp-refresh-{}", Uuid::new_v4()),
scope: Some(form.scope),
};
// update session token
if let Err(e) = state
.update_auth_session_token(&session_id, created_token)
.await
{
error!("Failed to update session token: {}", e);
}
// redirect back to client, with authorization code
let redirect_url = format!(
"{}?code={}{}",
form.redirect_uri,
auth_code,
if form.state.is_empty() {
"".to_string()
} else {
format!("&state={}", form.state)
}
);
info!("authorization approved, redirecting to: {}", redirect_url);
Redirect::to(&redirect_url).into_response()
}
// Handle token request from the MCP client
async fn oauth_token(
State(state): State<Arc<McpOAuthStore>>,
request: axum::http::Request<Body>,
) -> impl IntoResponse {
info!("Received token request");
let bytes = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
Ok(bytes) => bytes,
Err(e) => {
error!("can't read request body: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "can't read request body"
})),
)
.into_response();
}
};
let body_str = String::from_utf8_lossy(&bytes);
info!("request body: {}", body_str);
let token_req = match serde_urlencoded::from_bytes::<TokenRequest>(&bytes) {
Ok(form) => {
info!("successfully parsed form data: {:?}", form);
form
}
Err(e) => {
error!("can't parse form data: {}", e);
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": format!("can't parse form data: {}", e)
})),
)
.into_response();
}
};
if token_req.grant_type == "refresh_token" {
warn!("this easy server only support authorization_code now");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "unsupported_grant_type",
"error_description": "only authorization_code is supported"
})),
)
.into_response();
}
if token_req.grant_type != "authorization_code" {
info!("unsupported grant type: {}", token_req.grant_type);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "unsupported_grant_type",
"error_description": "only authorization_code is supported"
})),
)
.into_response();
}
// get session_id from code
if !token_req.code.starts_with("mcp-code-") {
info!("invalid authorization code: {}", token_req.code);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_grant",
"error_description": "invalid authorization code"
})),
)
.into_response();
}
// handle empty client_id
let client_id = if token_req.client_id.is_empty() {
"mcp-client".to_string()
} else {
token_req.client_id.clone()
};
// validate client
match state
.validate_client(&client_id, &token_req.redirect_uri)
.await
{
Some(_) => {
let session_id = token_req.code.replace("mcp-code-", "");
info!("got session id: {}", session_id);
// create mcp access token
match state.create_mcp_token(&session_id).await {
Ok(token) => {
info!("successfully created access token");
(
StatusCode::OK,
Json(serde_json::json!({
"access_token": token.access_token,
"token_type": token.token_type,
"expires_in": token.expires_in,
"refresh_token": token.refresh_token,
"scope": token.scope,
})),
)
.into_response()
}
Err(e) => {
error!("failed to create access token: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("failed to create access token: {}", e)
})),
)
.into_response()
}
}
}
None => {
info!(
"invalid client id or redirect uri: {} / {}",
client_id, token_req.redirect_uri
);
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client",
"error_description": "invalid client id or redirect uri"
})),
)
.into_response()
}
}
}
// Auth middleware for MCP connections
async fn validate_token_middleware(
State(token_store): State<Arc<McpOAuthStore>>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
debug!("validate_token_middleware");
// Extract the access token from the Authorization header
let auth_header = request.headers().get("Authorization");
let token = match auth_header {
Some(header) => {
let header_str = header.to_str().unwrap_or("");
if let Some(stripped) = header_str.strip_prefix("Bearer ") {
stripped.to_string()
} else {
return StatusCode::UNAUTHORIZED.into_response();
}
}
None => {
return StatusCode::UNAUTHORIZED.into_response();
}
};
// Validate the token
match token_store.validate_token(&token).await {
Some(_) => next.run(request).await,
None => StatusCode::UNAUTHORIZED.into_response(),
}
}
// handle oauth server metadata request
async fn oauth_authorization_server() -> impl IntoResponse {
let mut additional_fields = HashMap::new();
additional_fields.insert(
"response_types_supported".into(),
Value::Array(vec![Value::String("code".into())]),
);
let mut metadata = AuthorizationMetadata::default();
metadata.authorization_endpoint = format!("http://{}/oauth/authorize", BIND_ADDRESS);
metadata.token_endpoint = format!("http://{}/oauth/token", BIND_ADDRESS);
metadata.scopes_supported = Some(vec!["profile".to_string(), "email".to_string()]);
metadata.registration_endpoint = Some(format!("http://{}/oauth/register", BIND_ADDRESS));
metadata.response_types_supported = Some(vec!["code".to_string()]);
metadata.code_challenge_methods_supported = Some(vec!["S256".to_string()]);
metadata.issuer = Some(BIND_ADDRESS.to_string());
metadata.jwks_uri = Some(format!("http://{}/oauth/jwks", BIND_ADDRESS));
metadata.additional_fields = additional_fields;
debug!("metadata: {:?}", metadata);
(StatusCode::OK, Json(metadata))
}
// handle client registration request
async fn oauth_register(
State(state): State<Arc<McpOAuthStore>>,
Json(req): Json<LocalClientRegistrationRequest>,
) -> impl IntoResponse {
debug!("register request: {:?}", req);
if req.redirect_uris.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "at least one redirect uri is required"
})),
)
.into_response();
}
// generate client id and secret
let client_id = format!("client-{}", Uuid::new_v4());
let client_secret = generate_random_string(32);
let client = OAuthClientConfig::new(client_id.clone(), req.redirect_uris[0].clone())
.with_client_secret(client_secret.clone());
state
.clients
.write()
.await
.insert(client_id.clone(), client);
// return client information
let mut response = ClientRegistrationResponse::new(client_id, req.redirect_uris);
response.client_secret = Some(client_secret);
response.client_name = Some(req.client_name);
(StatusCode::CREATED, Json(response)).into_response()
}
// Log all HTTP requests
async fn log_request(request: Request<Body>, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let version = request.version();
// Log headers
let headers = request.headers().clone();
let mut header_log = String::new();
for (key, value) in headers.iter() {
let value_str = value.to_str().unwrap_or("<binary>");
header_log.push_str(&format!("\n {}: {}", key, value_str));
}
// Try to get request body for form submissions
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let request_info = if content_type.contains("application/x-www-form-urlencoded")
|| content_type.contains("application/json")
{
format!(
"{} {} {:?}{}\nContent-Type: {}",
method, uri, version, header_log, content_type
)
} else {
format!("{} {} {:?}{}", method, uri, version, header_log)
};
info!("REQUEST: {}", request_info);
// Call the actual handler
let response = next.run(request).await;
// Log response status
let status = response.status();
info!("RESPONSE: {} for {} {}", status, method, uri);
response
}
#[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();
// Create the OAuth store
let oauth_store = Arc::new(McpOAuthStore::new());
// Set up port
let addr = BIND_ADDRESS.parse::<SocketAddr>()?;
// Create streamable HTTP service for MCP
let mcp_service: StreamableHttpService<Counter, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
// Create protected MCP routes (require authorization)
let protected_mcp_router =
Router::new()
.nest_service("/mcp", mcp_service)
.layer(middleware::from_fn_with_state(
oauth_store.clone(),
validate_token_middleware,
));
// Create CORS layer for the oauth authorization server endpoint
let cors_layer = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Create a sub-router for the oauth authorization server endpoint with CORS
let oauth_server_router = Router::new()
.route(
"/.well-known/oauth-authorization-server",
get(oauth_authorization_server).options(oauth_authorization_server),
)
.route("/oauth/token", post(oauth_token).options(oauth_token))
.route(
"/oauth/register",
post(oauth_register).options(oauth_register),
)
.layer(cors_layer)
.with_state(oauth_store.clone());
// Create HTTP router with request logging middleware
let app = Router::new()
.route("/", get(index))
.route("/oauth/authorize", get(oauth_authorize))
.route("/oauth/approve", post(oauth_approve))
.merge(oauth_server_router) // Merge the CORS-enabled oauth server router
.merge(protected_mcp_router)
.with_state(oauth_store.clone())
.layer(middleware::from_fn(log_request));
// Start HTTP server
info!("MCP OAuth Server started on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
info!("Shutting down...");
})
.await?;
Ok(())
}