chore: add oauth2 support (#130)
1.add oauth2 server and client example 2.add oauth2 impl in sse 3.add auth doc Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
This commit is contained in:
parent
b1542a6153
commit
afb8a905e5
15 changed files with 2480 additions and 7 deletions
|
|
@ -179,6 +179,10 @@ For many cases you need to manage several service in a collection, you can call
|
|||
let service = service.into_dyn();
|
||||
```
|
||||
|
||||
### OAuth Support
|
||||
|
||||
See [docs/OAUTH_SUPPORT.md](docs/OAUTH_SUPPORT.md) for details.
|
||||
|
||||
### Examples
|
||||
|
||||
See [examples](examples/README.md)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ tracing = { version = "0.1" }
|
|||
tokio-util = { version = "0.7" }
|
||||
pin-project-lite = "0.2"
|
||||
paste = { version = "1", optional = true }
|
||||
|
||||
# oauth2 support
|
||||
oauth2 = { version = "5.0", optional = true }
|
||||
|
||||
# for auto generate schema
|
||||
schemars = { version = "0.8", optional = true }
|
||||
|
||||
|
|
@ -70,6 +74,8 @@ transport-sse-server = [
|
|||
]
|
||||
# transport-ws = ["transport-io", "dep:tokio-tungstenite"]
|
||||
tower = ["dep:tower-service"]
|
||||
auth = ["dep:oauth2", "dep:reqwest", "dep:url"]
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
schemars = { version = "0.8" }
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ pub mod sse;
|
|||
#[cfg(feature = "transport-sse")]
|
||||
pub use sse::SseTransport;
|
||||
|
||||
#[cfg(all(feature = "transport-sse", feature = "auth"))]
|
||||
pub mod sse_auth;
|
||||
#[cfg(all(feature = "transport-sse", feature = "auth"))]
|
||||
pub use sse_auth::{AuthorizedSseClient, create_authorized_transport};
|
||||
|
||||
// #[cfg(feature = "tower")]
|
||||
// pub mod tower;
|
||||
|
||||
|
|
@ -64,6 +69,11 @@ pub mod sse_server;
|
|||
#[cfg(feature = "transport-sse-server")]
|
||||
pub use sse_server::SseServer;
|
||||
|
||||
#[cfg(feature = "auth")]
|
||||
pub mod auth;
|
||||
#[cfg(feature = "auth")]
|
||||
pub use auth::{AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient};
|
||||
|
||||
// #[cfg(feature = "transport-ws")]
|
||||
// pub mod ws;
|
||||
|
||||
|
|
|
|||
718
crates/rmcp/src/transport/auth.rs
Normal file
718
crates/rmcp/src/transport/auth.rs
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use oauth2::{
|
||||
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields,
|
||||
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, StandardTokenResponse,
|
||||
TokenResponse, TokenUrl,
|
||||
basic::{BasicClient, BasicTokenType},
|
||||
};
|
||||
use reqwest::{Client as HttpClient, IntoUrl, StatusCode, Url, header::AUTHORIZATION};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error};
|
||||
|
||||
/// Auth error
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("OAuth authorization required")]
|
||||
AuthorizationRequired,
|
||||
|
||||
#[error("OAuth authorization failed: {0}")]
|
||||
AuthorizationFailed(String),
|
||||
|
||||
#[error("OAuth token exchange failed: {0}")]
|
||||
TokenExchangeFailed(String),
|
||||
|
||||
#[error("OAuth token refresh failed: {0}")]
|
||||
TokenRefreshFailed(String),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
|
||||
#[error("OAuth error: {0}")]
|
||||
OAuthError(String),
|
||||
|
||||
#[error("Metadata error: {0}")]
|
||||
MetadataError(String),
|
||||
|
||||
#[error("URL parse error: {0}")]
|
||||
UrlError(#[from] url::ParseError),
|
||||
|
||||
#[error("No authorization support detected")]
|
||||
NoAuthorizationSupport,
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("Invalid token type: {0}")]
|
||||
InvalidTokenType(String),
|
||||
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Invalid scope: {0}")]
|
||||
InvalidScope(String),
|
||||
|
||||
#[error("Registration failed: {0}")]
|
||||
RegistrationFailed(String),
|
||||
}
|
||||
|
||||
/// oauth2 metadata
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AuthorizationMetadata {
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub registration_endpoint: String,
|
||||
pub issuer: Option<String>,
|
||||
pub jwks_uri: Option<String>,
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// oauth2 client config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthClientConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
// add type aliases for oauth2 types
|
||||
type OAuthErrorResponse = oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>;
|
||||
type OAuthTokenResponse = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
|
||||
type OAuthTokenIntrospection =
|
||||
oauth2::StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>;
|
||||
type OAuthRevocableToken = oauth2::StandardRevocableToken;
|
||||
type OAuthRevocationError = oauth2::StandardErrorResponse<oauth2::RevocationErrorResponseType>;
|
||||
type OAuthClient = oauth2::Client<
|
||||
OAuthErrorResponse,
|
||||
OAuthTokenResponse,
|
||||
OAuthTokenIntrospection,
|
||||
OAuthRevocableToken,
|
||||
OAuthRevocationError,
|
||||
oauth2::EndpointSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointSet,
|
||||
>;
|
||||
|
||||
/// oauth2 auth manager
|
||||
pub struct AuthorizationManager {
|
||||
http_client: HttpClient,
|
||||
metadata: Option<AuthorizationMetadata>,
|
||||
oauth_client: Option<OAuthClient>,
|
||||
credentials: RwLock<Option<OAuthTokenResponse>>,
|
||||
pkce_verifier: RwLock<Option<PkceCodeVerifier>>,
|
||||
expires_at: RwLock<Option<Instant>>,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientRegistrationRequest {
|
||||
pub client_name: String,
|
||||
pub redirect_uris: Vec<String>,
|
||||
pub grant_types: Vec<String>,
|
||||
pub token_endpoint_auth_method: String,
|
||||
pub response_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientRegistrationResponse {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub client_name: String,
|
||||
pub redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthorizationManager {
|
||||
/// create new auth manager with base url
|
||||
pub async fn new<U: IntoUrl>(base_url: U) -> Result<Self, AuthError> {
|
||||
let base_url = base_url.into_url()?;
|
||||
let http_client = HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
|
||||
let manager = Self {
|
||||
http_client,
|
||||
metadata: None,
|
||||
oauth_client: None,
|
||||
credentials: RwLock::new(None),
|
||||
pkce_verifier: RwLock::new(None),
|
||||
expires_at: RwLock::new(None),
|
||||
base_url,
|
||||
};
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn with_client(&mut self, http_client: HttpClient) -> Result<(), AuthError> {
|
||||
self.http_client = http_client;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// discover oauth2 metadata
|
||||
pub async fn discover_metadata(&self) -> Result<AuthorizationMetadata, AuthError> {
|
||||
// according to the specification, the metadata should be located at "/.well-known/oauth-authorization-server"
|
||||
let mut discovery_url = self.base_url.clone();
|
||||
discovery_url.set_path("/.well-known/oauth-authorization-server");
|
||||
debug!("discovery url: {:?}", discovery_url);
|
||||
let response = self
|
||||
.http_client
|
||||
.get(discovery_url)
|
||||
.header("MCP-Protocol-Version", "2024-11-05")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
let metadata = response
|
||||
.json::<AuthorizationMetadata>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AuthError::MetadataError(format!("Failed to parse metadata: {}", e))
|
||||
})?;
|
||||
debug!("metadata: {:?}", metadata);
|
||||
Ok(metadata)
|
||||
} else {
|
||||
// fallback to default endpoints
|
||||
let mut auth_base = self.base_url.clone();
|
||||
// discard the path part, only keep scheme, host, port
|
||||
auth_base.set_path("");
|
||||
|
||||
Ok(AuthorizationMetadata {
|
||||
authorization_endpoint: format!("{}/authorize", auth_base),
|
||||
token_endpoint: format!("{}/token", auth_base),
|
||||
registration_endpoint: format!("{}/register", auth_base),
|
||||
issuer: None,
|
||||
jwks_uri: None,
|
||||
scopes_supported: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// configure oauth2 client with client credentials
|
||||
pub fn configure_client(&mut self, config: OAuthClientConfig) -> Result<(), AuthError> {
|
||||
if self.metadata.is_none() {
|
||||
return Err(AuthError::NoAuthorizationSupport);
|
||||
}
|
||||
|
||||
let metadata = self.metadata.as_ref().unwrap();
|
||||
|
||||
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
|
||||
.map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?;
|
||||
|
||||
let token_url = TokenUrl::new(metadata.token_endpoint.clone())
|
||||
.map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?;
|
||||
|
||||
// debug!("token url: {:?}", token_url);
|
||||
let client_id = ClientId::new(config.client_id);
|
||||
let redirect_url = RedirectUrl::new(config.redirect_uri.clone())
|
||||
.map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?;
|
||||
|
||||
debug!("client_id: {:?}", client_id);
|
||||
let mut client_builder = BasicClient::new(client_id.clone())
|
||||
.set_auth_uri(auth_url)
|
||||
.set_token_uri(token_url)
|
||||
.set_redirect_uri(redirect_url);
|
||||
|
||||
if let Some(secret) = config.client_secret {
|
||||
client_builder = client_builder.set_client_secret(ClientSecret::new(secret));
|
||||
}
|
||||
|
||||
self.oauth_client = Some(client_builder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// dynamic register oauth2 client
|
||||
pub async fn register_client(
|
||||
&mut self,
|
||||
name: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<OAuthClientConfig, AuthError> {
|
||||
if self.metadata.is_none() {
|
||||
error!("No authorization support detected");
|
||||
return Err(AuthError::NoAuthorizationSupport);
|
||||
}
|
||||
|
||||
let metadata = self.metadata.as_ref().unwrap();
|
||||
let registration_url = metadata.registration_endpoint.clone();
|
||||
|
||||
debug!("registration url: {:?}", registration_url);
|
||||
// prepare registration request
|
||||
let registration_request = ClientRegistrationRequest {
|
||||
client_name: name.to_string(),
|
||||
redirect_uris: vec![redirect_uri.to_string()],
|
||||
grant_types: vec![
|
||||
"authorization_code".to_string(),
|
||||
"refresh_token".to_string(),
|
||||
],
|
||||
token_endpoint_auth_method: "none".to_string(), // public client
|
||||
response_types: vec!["code".to_string()],
|
||||
};
|
||||
|
||||
debug!("registration request: {:?}", registration_request);
|
||||
|
||||
let response = match self
|
||||
.http_client
|
||||
.post(registration_url)
|
||||
.json(®istration_request)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
error!("Registration request failed: {}", e);
|
||||
return Err(AuthError::RegistrationFailed(format!(
|
||||
"HTTP request error: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(_) => "cannot get error details".to_string(),
|
||||
};
|
||||
|
||||
error!("Registration failed: HTTP {} - {}", status, error_text);
|
||||
return Err(AuthError::RegistrationFailed(format!(
|
||||
"HTTP {}: {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
debug!("registration response: {:?}", response);
|
||||
let reg_response = match response.json::<ClientRegistrationResponse>().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
error!("Failed to parse registration response: {}", e);
|
||||
return Err(AuthError::RegistrationFailed(format!(
|
||||
"analyze response error: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let config = OAuthClientConfig {
|
||||
client_id: reg_response.client_id,
|
||||
client_secret: Some(reg_response.client_secret),
|
||||
redirect_uri: redirect_uri.to_string(),
|
||||
scopes: vec![],
|
||||
};
|
||||
|
||||
self.configure_client(config.clone())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// generate authorization url
|
||||
pub async fn get_authorization_url(&self, scopes: &[&str]) -> Result<String, AuthError> {
|
||||
let oauth_client = self
|
||||
.oauth_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
|
||||
|
||||
// generate pkce challenge
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// build authorization request
|
||||
let mut auth_request = oauth_client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.set_pkce_challenge(pkce_challenge);
|
||||
|
||||
// add request scopes
|
||||
for scope in scopes {
|
||||
auth_request = auth_request.add_scope(Scope::new(scope.to_string()));
|
||||
}
|
||||
|
||||
let (auth_url, _csrf_token) = auth_request.url();
|
||||
|
||||
// store pkce verifier for later use
|
||||
*self.pkce_verifier.write().await = Some(pkce_verifier);
|
||||
debug!("set pkce verifier: {:?}", self.pkce_verifier.read().await);
|
||||
|
||||
Ok(auth_url.to_string())
|
||||
}
|
||||
|
||||
/// exchange authorization code for access token
|
||||
pub async fn exchange_code_for_token(
|
||||
&self,
|
||||
code: &str,
|
||||
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
|
||||
debug!("start exchange code for token: {:?}", code);
|
||||
let oauth_client = self
|
||||
.oauth_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
|
||||
|
||||
let pkce_verifier = self
|
||||
.pkce_verifier
|
||||
.write()
|
||||
.await
|
||||
.take()
|
||||
.ok_or_else(|| AuthError::InternalError("PKCE verifier not found".to_string()))?;
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
debug!("client_id: {:?}", oauth_client.client_id());
|
||||
|
||||
// exchange token
|
||||
let token_result = oauth_client
|
||||
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||
.add_extra_param("client_id", oauth_client.client_id().to_string())
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(&http_client)
|
||||
.await
|
||||
.map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
|
||||
|
||||
// get expires_in from token response
|
||||
let expires_in = token_result.expires_in();
|
||||
if let Some(expires_in) = expires_in {
|
||||
let expires_at = Instant::now() + expires_in;
|
||||
*self.expires_at.write().await = Some(expires_at);
|
||||
}
|
||||
debug!("exchange token result: {:?}", token_result);
|
||||
// store credentials
|
||||
*self.credentials.write().await = Some(token_result.clone());
|
||||
|
||||
Ok(token_result)
|
||||
}
|
||||
|
||||
/// get access token, if expired, refresh it automatically
|
||||
pub async fn get_access_token(&self) -> Result<String, AuthError> {
|
||||
let credentials = self.credentials.read().await;
|
||||
|
||||
if let Some(creds) = credentials.as_ref() {
|
||||
// check if the token is expire
|
||||
if let Some(expires_at) = *self.expires_at.read().await {
|
||||
if expires_at < Instant::now() {
|
||||
// token expired, try to refresh , release the lock
|
||||
drop(credentials);
|
||||
let new_creds = self.refresh_token().await?;
|
||||
return Ok(new_creds.access_token().secret().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(creds.access_token().secret().to_string())
|
||||
} else {
|
||||
Err(AuthError::AuthorizationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
/// refresh access token
|
||||
pub async fn refresh_token(
|
||||
&self,
|
||||
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
|
||||
let oauth_client = self
|
||||
.oauth_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
|
||||
|
||||
let current_credentials = self
|
||||
.credentials
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| AuthError::AuthorizationRequired)?;
|
||||
|
||||
let refresh_token = current_credentials.refresh_token().ok_or_else(|| {
|
||||
AuthError::TokenRefreshFailed("No refresh token available".to_string())
|
||||
})?;
|
||||
debug!("refresh token: {:?}", refresh_token);
|
||||
// refresh token
|
||||
let token_result = oauth_client
|
||||
.exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string()))
|
||||
.request_async(&self.http_client)
|
||||
.await
|
||||
.map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?;
|
||||
|
||||
// store new credentials
|
||||
*self.credentials.write().await = Some(token_result.clone());
|
||||
|
||||
// get expires_in from token response
|
||||
let expires_in = token_result.expires_in();
|
||||
if let Some(expires_in) = expires_in {
|
||||
let expires_at = Instant::now() + expires_in;
|
||||
*self.expires_at.write().await = Some(expires_at);
|
||||
}
|
||||
Ok(token_result)
|
||||
}
|
||||
|
||||
/// prepare request, add authorization header
|
||||
pub async fn prepare_request(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::RequestBuilder, AuthError> {
|
||||
let token = self.get_access_token().await?;
|
||||
Ok(request.header(AUTHORIZATION, format!("Bearer {}", token)))
|
||||
}
|
||||
|
||||
/// handle response, check if need to re-authorize
|
||||
pub async fn handle_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, AuthError> {
|
||||
if response.status() == StatusCode::UNAUTHORIZED {
|
||||
// 401 Unauthorized, need to re-authorize
|
||||
Err(AuthError::AuthorizationRequired)
|
||||
} else {
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// oauth2 authorization session, for guiding user to complete the authorization process
|
||||
pub struct AuthorizationSession {
|
||||
pub auth_manager: AuthorizationManager,
|
||||
pub auth_url: String,
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
impl AuthorizationSession {
|
||||
/// create new authorization session
|
||||
pub async fn new(
|
||||
mut auth_manager: AuthorizationManager,
|
||||
scopes: &[&str],
|
||||
redirect_uri: &str,
|
||||
) -> Result<Self, AuthError> {
|
||||
// set redirect uri
|
||||
let config = OAuthClientConfig {
|
||||
client_id: "mcp-client".to_string(), // temporary id, will be updated by dynamic registration
|
||||
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("MCP Client", redirect_uri)
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Dynamic registration failed: {}", e);
|
||||
// fallback to default config
|
||||
config
|
||||
}
|
||||
};
|
||||
// reset client config
|
||||
auth_manager.configure_client(config)?;
|
||||
let auth_url = auth_manager.get_authorization_url(scopes).await?;
|
||||
|
||||
Ok(Self {
|
||||
auth_manager,
|
||||
auth_url,
|
||||
redirect_uri: redirect_uri.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// get authorization url
|
||||
pub fn get_authorization_url(&self) -> &str {
|
||||
&self.auth_url
|
||||
}
|
||||
|
||||
/// handle authorization code callback
|
||||
pub async fn handle_callback(
|
||||
&self,
|
||||
code: &str,
|
||||
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
|
||||
self.auth_manager.exchange_code_for_token(code).await
|
||||
}
|
||||
}
|
||||
|
||||
/// http client extension, automatically add authorization header
|
||||
pub struct AuthorizedHttpClient {
|
||||
auth_manager: Arc<AuthorizationManager>,
|
||||
inner_client: HttpClient,
|
||||
}
|
||||
|
||||
impl AuthorizedHttpClient {
|
||||
/// create new authorized http client
|
||||
pub fn new(auth_manager: Arc<AuthorizationManager>, client: Option<HttpClient>) -> Self {
|
||||
let inner_client = client.unwrap_or_default();
|
||||
Self {
|
||||
auth_manager,
|
||||
inner_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// send authorized request
|
||||
pub async fn request<U: IntoUrl>(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
url: U,
|
||||
) -> Result<reqwest::RequestBuilder, AuthError> {
|
||||
let request = self.inner_client.request(method, url);
|
||||
self.auth_manager.prepare_request(request).await
|
||||
}
|
||||
|
||||
/// send get request
|
||||
pub async fn get<U: IntoUrl>(&self, url: U) -> Result<reqwest::Response, AuthError> {
|
||||
let request = self.request(reqwest::Method::GET, url).await?;
|
||||
let response = request.send().await?;
|
||||
self.auth_manager.handle_response(response).await
|
||||
}
|
||||
|
||||
/// send post request
|
||||
pub async fn post<U: IntoUrl>(&self, url: U) -> Result<reqwest::RequestBuilder, AuthError> {
|
||||
self.request(reqwest::Method::POST, url).await
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth state machine
|
||||
/// Use the OAuthState to manage the OAuth client is more recommend
|
||||
/// But also you can use the AuthorizationManager,AuthorizationSession,AuthorizedHttpClient directly
|
||||
pub enum OAuthState {
|
||||
/// the AuthorizationManager
|
||||
Unauthorized(AuthorizationManager),
|
||||
/// the AuthorizationSession
|
||||
Session(AuthorizationSession),
|
||||
/// the authd AuthorizationManager
|
||||
Authorized(AuthorizationManager),
|
||||
/// the authd http client
|
||||
AuthorizedHttpClient(AuthorizedHttpClient),
|
||||
}
|
||||
|
||||
impl OAuthState {
|
||||
/// Create new OAuth state machine
|
||||
pub async fn new<U: IntoUrl>(
|
||||
base_url: U,
|
||||
client: Option<HttpClient>,
|
||||
) -> Result<Self, AuthError> {
|
||||
let mut manager = AuthorizationManager::new(base_url).await?;
|
||||
if let Some(client) = client {
|
||||
manager.with_client(client)?;
|
||||
}
|
||||
Ok(OAuthState::Unauthorized(manager))
|
||||
}
|
||||
|
||||
/// start authorization
|
||||
pub async fn start_authorization(
|
||||
&mut self,
|
||||
scopes: &[&str],
|
||||
redirect_uri: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(
|
||||
self,
|
||||
OAuthState::Unauthorized(AuthorizationManager::new("http://localhost").await?),
|
||||
) {
|
||||
debug!("start discovery");
|
||||
let metadata = manager.discover_metadata().await?;
|
||||
manager.metadata = Some(metadata);
|
||||
debug!("start session");
|
||||
let session = AuthorizationSession::new(manager, scopes, redirect_uri).await?;
|
||||
*self = OAuthState::Session(session);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::InternalError(
|
||||
"Already in session state".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// complete authorization
|
||||
pub async fn complete_authorization(&mut self) -> Result<(), AuthError> {
|
||||
if let OAuthState::Session(session) = std::mem::replace(
|
||||
self,
|
||||
OAuthState::Unauthorized(AuthorizationManager::new("http://localhost").await?),
|
||||
) {
|
||||
*self = OAuthState::Authorized(session.auth_manager);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::InternalError("Not in session state".to_string()))
|
||||
}
|
||||
}
|
||||
/// covert to authorized http client
|
||||
pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> {
|
||||
if let OAuthState::Authorized(manager) = std::mem::replace(
|
||||
self,
|
||||
OAuthState::Authorized(AuthorizationManager::new("http://localhost").await?),
|
||||
) {
|
||||
*self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new(
|
||||
Arc::new(manager),
|
||||
None,
|
||||
));
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::InternalError(
|
||||
"Not in authorized state".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
/// get current authorization url
|
||||
pub async fn get_authorization_url(&self) -> Result<String, AuthError> {
|
||||
match self {
|
||||
OAuthState::Session(session) => Ok(session.get_authorization_url().to_string()),
|
||||
OAuthState::Unauthorized(_) => {
|
||||
Err(AuthError::InternalError("Not in session state".to_string()))
|
||||
}
|
||||
OAuthState::Authorized(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
OAuthState::AuthorizedHttpClient(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// handle authorization callback
|
||||
pub async fn handle_callback(&mut self, code: &str) -> Result<(), AuthError> {
|
||||
match self {
|
||||
OAuthState::Session(session) => {
|
||||
session.handle_callback(code).await?;
|
||||
self.complete_authorization().await
|
||||
}
|
||||
OAuthState::Unauthorized(_) => {
|
||||
Err(AuthError::InternalError("Not in session state".to_string()))
|
||||
}
|
||||
OAuthState::Authorized(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
OAuthState::AuthorizedHttpClient(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// get access token
|
||||
pub async fn get_access_token(&self) -> Result<String, AuthError> {
|
||||
match self {
|
||||
OAuthState::Unauthorized(manager) => manager.get_access_token().await,
|
||||
OAuthState::Session(_) => {
|
||||
Err(AuthError::InternalError("Not in manager state".to_string()))
|
||||
}
|
||||
OAuthState::Authorized(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
OAuthState::AuthorizedHttpClient(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// refresh access token
|
||||
pub async fn refresh_token(&self) -> Result<(), AuthError> {
|
||||
match self {
|
||||
OAuthState::Unauthorized(_) => {
|
||||
Err(AuthError::InternalError("Not in manager state".to_string()))
|
||||
}
|
||||
OAuthState::Session(_) => {
|
||||
Err(AuthError::InternalError("Not in manager state".to_string()))
|
||||
}
|
||||
OAuthState::Authorized(manager) => {
|
||||
manager.refresh_token().await?;
|
||||
Ok(())
|
||||
}
|
||||
OAuthState::AuthorizedHttpClient(_) => {
|
||||
Err(AuthError::InternalError("Already authorized".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
crates/rmcp/src/transport/sse_auth.rs
Normal file
183
crates/rmcp/src/transport/sse_auth.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
|
||||
use reqwest::{
|
||||
Client as HttpClient, IntoUrl, Url,
|
||||
header::{ACCEPT, AUTHORIZATION},
|
||||
};
|
||||
use sse_stream::{Error as SseError, Sse, SseStream};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{
|
||||
auth::{AuthError, AuthorizationManager, OAuthState},
|
||||
sse::{SseClient, SseTransport, SseTransportError, SseTransportRetryConfig},
|
||||
};
|
||||
use crate::model::ClientJsonRpcMessage;
|
||||
|
||||
// SSE MIME type
|
||||
const MIME_TYPE: &str = "text/event-stream";
|
||||
const HEADER_LAST_EVENT_ID: &str = "Last-Event-ID";
|
||||
|
||||
/// sse client with oauth2 authorization
|
||||
#[derive(Clone)]
|
||||
pub struct AuthorizedSseClient {
|
||||
http_client: HttpClient,
|
||||
sse_url: Url,
|
||||
auth_manager: Arc<Mutex<AuthorizationManager>>,
|
||||
_retry_config: Option<SseTransportRetryConfig>, // TODO retry config may be used by authorized transport for token
|
||||
}
|
||||
|
||||
impl AuthorizedSseClient {
|
||||
/// create new authorized sse client
|
||||
pub fn new<U>(
|
||||
url: U,
|
||||
auth_manager: Arc<Mutex<AuthorizationManager>>,
|
||||
retry_config: Option<SseTransportRetryConfig>,
|
||||
) -> Result<Self, SseTransportError<reqwest::Error>>
|
||||
where
|
||||
U: IntoUrl,
|
||||
{
|
||||
let url = url.into_url().map_err(SseTransportError::from)?;
|
||||
Ok(Self {
|
||||
http_client: HttpClient::default(),
|
||||
sse_url: url,
|
||||
auth_manager,
|
||||
_retry_config: retry_config,
|
||||
})
|
||||
}
|
||||
|
||||
/// create authorized sse client with custom http client
|
||||
pub async fn new_with_client<U>(
|
||||
url: U,
|
||||
client: HttpClient,
|
||||
auth_manager: Arc<Mutex<AuthorizationManager>>,
|
||||
retry_config: Option<SseTransportRetryConfig>,
|
||||
) -> Result<Self, SseTransportError<reqwest::Error>>
|
||||
where
|
||||
U: IntoUrl,
|
||||
{
|
||||
let url = url.into_url().map_err(SseTransportError::from)?;
|
||||
Ok(Self {
|
||||
http_client: client,
|
||||
sse_url: url,
|
||||
auth_manager,
|
||||
_retry_config: retry_config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SseClient<reqwest::Error> for AuthorizedSseClient {
|
||||
fn connect(
|
||||
&self,
|
||||
last_event_id: Option<String>,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<BoxStream<'static, Result<Sse, SseError>>, SseTransportError<reqwest::Error>>,
|
||||
> {
|
||||
let client = self.http_client.clone();
|
||||
let sse_url = self.sse_url.as_ref().to_string();
|
||||
let last_event_id = last_event_id.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
|
||||
let fut = async move {
|
||||
// get access token
|
||||
let token = auth_manager.lock().await.get_access_token().await?;
|
||||
|
||||
// build request
|
||||
let mut request_builder = client
|
||||
.get(&sse_url)
|
||||
.header(ACCEPT, MIME_TYPE)
|
||||
.header(AUTHORIZATION, format!("Bearer {}", token));
|
||||
|
||||
if let Some(last_event_id) = last_event_id {
|
||||
request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id);
|
||||
}
|
||||
|
||||
let response = request_builder.send().await?;
|
||||
let response = response.error_for_status()?;
|
||||
|
||||
match response.headers().get(reqwest::header::CONTENT_TYPE) {
|
||||
Some(ct) => {
|
||||
if !ct.as_bytes().starts_with(MIME_TYPE.as_bytes()) {
|
||||
return Err(SseTransportError::UnexpectedContentType(Some(ct.clone())));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(SseTransportError::UnexpectedContentType(None));
|
||||
}
|
||||
}
|
||||
|
||||
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
|
||||
Ok(event_stream)
|
||||
};
|
||||
|
||||
Box::pin(fut)
|
||||
}
|
||||
|
||||
fn post(
|
||||
&self,
|
||||
session_id: &str,
|
||||
message: ClientJsonRpcMessage,
|
||||
) -> BoxFuture<'static, Result<(), SseTransportError<reqwest::Error>>> {
|
||||
let client = self.http_client.clone();
|
||||
let sse_url = self.sse_url.clone();
|
||||
let session_id = session_id.to_string();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
// get access token
|
||||
let token = auth_manager
|
||||
.lock()
|
||||
.await
|
||||
.get_access_token()
|
||||
.await
|
||||
.map_err(SseTransportError::<reqwest::Error>::from)?;
|
||||
|
||||
let uri = sse_url.join(&session_id).map_err(SseTransportError::from)?;
|
||||
let request_builder = client
|
||||
.post(uri.as_ref())
|
||||
.header(AUTHORIZATION, format!("Bearer {}", token))
|
||||
.json(&message);
|
||||
|
||||
request_builder
|
||||
.send()
|
||||
.await
|
||||
.and_then(|resp| resp.error_for_status())
|
||||
.map_err(SseTransportError::from)
|
||||
.map(drop)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for SseTransportError<reqwest::Error> {
|
||||
fn from(err: AuthError) -> Self {
|
||||
SseTransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// create authorized sse transport
|
||||
pub async fn create_authorized_transport<U>(
|
||||
url: U,
|
||||
auth_state: OAuthState,
|
||||
retry_config: Option<SseTransportRetryConfig>,
|
||||
) -> Result<SseTransport<AuthorizedSseClient, reqwest::Error>, SseTransportError<reqwest::Error>>
|
||||
where
|
||||
U: IntoUrl,
|
||||
{
|
||||
match auth_state {
|
||||
OAuthState::Authorized(auth_manager) => {
|
||||
let auth_manager = Arc::new(Mutex::new(auth_manager));
|
||||
let client = AuthorizedSseClient::new(url, auth_manager, retry_config)?;
|
||||
let mut transport = SseTransport::start_with_client(client).await?;
|
||||
transport.retry_config = retry_config.unwrap_or_default();
|
||||
Ok(transport)
|
||||
}
|
||||
_ => Err(SseTransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Not authorized".to_string(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
125
docs/OAUTH_SUPPORT.md
Normal file
125
docs/OAUTH_SUPPORT.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Model Context Protocol OAuth Authorization
|
||||
|
||||
This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-03-26 Authorization Specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/).
|
||||
|
||||
## Features
|
||||
|
||||
- Full support for OAuth 2.1 authorization flow
|
||||
- PKCE support for enhanced security
|
||||
- Authorization server metadata discovery
|
||||
- Dynamic client registration
|
||||
- Automatic token refresh
|
||||
- Authorized SSE transport implementation
|
||||
- Authorized HTTP Client implementation
|
||||
## Usage Guide
|
||||
|
||||
### 1. Enable Features
|
||||
|
||||
Enable the auth feature in Cargo.toml:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rmcp = { version = "0.1", features = ["auth", "transport-sse"] }
|
||||
```
|
||||
|
||||
### 2. Use OAuthState
|
||||
|
||||
```rust ignore
|
||||
// Initialize oauth state machine
|
||||
let mut oauth_state = OAuthState::new(&server_url, None)
|
||||
.await
|
||||
.context("Failed to initialize oauth state machine")?;
|
||||
oauth_state
|
||||
.start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI)
|
||||
.await
|
||||
.context("Failed to start authorization")?;
|
||||
|
||||
```
|
||||
|
||||
### 3. Get authorization url and do callback
|
||||
|
||||
```rust ignore
|
||||
// Get authorization URL and guide user to open it
|
||||
let auth_url = oauth_state.get_authorization_url().await?;
|
||||
println!("Please open the following URL in your browser for authorization:\n{}", auth_url);
|
||||
|
||||
// Handle callback - In real applications, this is typically done in a callback server
|
||||
let auth_code = "Authorization code obtained from browser after user authorization";
|
||||
let credentials = oauth_state.handle_callback(auth_code).await?;
|
||||
|
||||
println!("Authorization successful, access token: {}", credentials.access_token);
|
||||
|
||||
```
|
||||
|
||||
### 4. Use Authorized SSE Transport and create client
|
||||
|
||||
```rust ignore
|
||||
let transport =
|
||||
match create_authorized_transport(MCP_SSE_URL.to_string(), oauth_state, Some(retry_config))
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create authorized transport: {}", e);
|
||||
return Err(anyhow::anyhow!("Connection failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// Create client and connect to MCP server
|
||||
let client_service = ClientInfo::default();
|
||||
let client = client_service.serve(transport).await?;
|
||||
```
|
||||
|
||||
### 5. May you can use Authorized HTTP Client after authorized
|
||||
|
||||
```rust ignore
|
||||
let client = oauth_state.to_authorized_http_client().await?;
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
client: Please refer to `examples/clients/src/oauth_client.rs` for a complete usage example.
|
||||
server: Please refer to `examples/servers/src/mcp_oauth_server.rs` for a complete usage example.
|
||||
### Running the Example in server
|
||||
```bash
|
||||
# Run example
|
||||
cargo run --example mcp_oauth_server
|
||||
```
|
||||
|
||||
### Running the Example in client
|
||||
|
||||
```bash
|
||||
# Run example
|
||||
cargo run --example oauth-client
|
||||
```
|
||||
|
||||
## Authorization Flow Description
|
||||
|
||||
1. **Metadata Discovery**: Client attempts to get authorization server metadata from `/.well-known/oauth-authorization-server`
|
||||
2. **Client Registration**: If supported, client dynamically registers itself
|
||||
3. **Authorization Request**: Build authorization URL with PKCE and guide user to access
|
||||
4. **Authorization Code Exchange**: After user authorization, exchange authorization code for access token
|
||||
5. **Token Usage**: Use access token for API calls
|
||||
6. **Token Refresh**: Automatically use refresh token to get new access token when current one expires
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All tokens are securely stored in memory
|
||||
- PKCE implementation prevents authorization code interception attacks
|
||||
- Automatic token refresh support reduces user intervention
|
||||
- Only accepts HTTPS connections or secure local callback URIs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter authorization issues, check the following:
|
||||
|
||||
1. Ensure server supports OAuth 2.1 authorization
|
||||
2. Verify callback URI matches server's allowed redirect URIs
|
||||
3. Check network connection and firewall settings
|
||||
4. Verify server supports metadata discovery or dynamic client registration
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Authorization Specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/)
|
||||
- [OAuth 2.1 Specification Draft](https://oauth.net/2.1/)
|
||||
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
- [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591)
|
||||
|
|
@ -11,7 +11,8 @@ rmcp = { path = "../../crates/rmcp", features = [
|
|||
"client",
|
||||
"transport-sse",
|
||||
"transport-child-process",
|
||||
"tower"
|
||||
"tower",
|
||||
"auth"
|
||||
] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
@ -21,8 +22,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||
rand = "0.8"
|
||||
futures = "0.3"
|
||||
anyhow = "1.0"
|
||||
|
||||
url = "2.4"
|
||||
tower = "0.5"
|
||||
axum = "0.8"
|
||||
|
||||
[[example]]
|
||||
name = "clients_sse"
|
||||
|
|
@ -40,3 +42,6 @@ path = "src/everything_stdio.rs"
|
|||
name = "clients_collection"
|
||||
path = "src/collection.rs"
|
||||
|
||||
[[example]]
|
||||
name = "oauth_client"
|
||||
path = "src/oauth_client.rs"
|
||||
93
examples/clients/src/callback.html
Normal file
93
examples/clients/src/callback.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OAuth Success</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4CAF50;
|
||||
--background-color: #f8f9fa;
|
||||
--text-color: #333;
|
||||
--border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
animation: scaleIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--primary-color);
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">✓</div>
|
||||
<h1>Authorization Success</h1>
|
||||
<p>You have successfully authorized the MCP client. You can now close this window and return to the application.</p>
|
||||
<button class="close-button" onclick="window.close()">Close Window</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
228
examples/clients/src/oauth_client.rs
Normal file
228
examples/clients/src/oauth_client.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Query, State},
|
||||
response::Html,
|
||||
routing::get,
|
||||
};
|
||||
use rmcp::{
|
||||
ServiceExt,
|
||||
model::ClientInfo,
|
||||
transport::{auth::OAuthState, create_authorized_transport, sse::SseTransportRetryConfig},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
|
||||
sync::{Mutex, oneshot},
|
||||
};
|
||||
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_SSE_URL: &str = "http://localhost:3000/mcp/sse";
|
||||
const CALLBACK_PORT: u16 = 8080;
|
||||
const CALLBACK_HTML: &str = include_str!("callback.html");
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
code_receiver: Arc<Mutex<Option<oneshot::Sender<String>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallbackParams {
|
||||
code: String,
|
||||
#[allow(dead_code)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
async fn callback_handler(
|
||||
Query(params): Query<CallbackParams>,
|
||||
State(state): State<AppState>,
|
||||
) -> Html<String> {
|
||||
tracing::info!("Received callback with code: {}", params.code);
|
||||
|
||||
// Send the code to the main thread
|
||||
if let Some(sender) = state.code_receiver.lock().await.take() {
|
||||
let _ = sender.send(params.code);
|
||||
}
|
||||
// Return success page
|
||||
Html(CALLBACK_HTML.to_string())
|
||||
}
|
||||
|
||||
#[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();
|
||||
// it is a http server for handling callback
|
||||
// Create channel for receiving authorization code
|
||||
let (code_sender, code_receiver) = oneshot::channel::<String>();
|
||||
|
||||
// Create app state
|
||||
let app_state = AppState {
|
||||
code_receiver: Arc::new(Mutex::new(Some(code_sender))),
|
||||
};
|
||||
|
||||
// Start HTTP server for handling callbacks
|
||||
let app = Router::new()
|
||||
.route("/callback", get(callback_handler))
|
||||
.with_state(app_state);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT));
|
||||
tracing::info!("Starting callback server at: http://{}", addr);
|
||||
|
||||
// Start server in a separate task
|
||||
tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
let result = axum::serve(listener, app).await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Callback server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Get server URL
|
||||
let server_url = MCP_SERVER_URL.to_string();
|
||||
tracing::info!("Using MCP server URL: {}", server_url);
|
||||
|
||||
// Configure retry settings
|
||||
let retry_config = SseTransportRetryConfig {
|
||||
max_times: Some(3),
|
||||
min_duration: Duration::from_secs(1),
|
||||
};
|
||||
// Initialize oauth state machine
|
||||
let mut oauth_state = OAuthState::new(&server_url, None)
|
||||
.await
|
||||
.context("Failed to initialize oauth state machine")?;
|
||||
oauth_state
|
||||
.start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI)
|
||||
.await
|
||||
.context("Failed to start authorization")?;
|
||||
|
||||
// Output authorization URL to user
|
||||
let mut output = BufWriter::new(tokio::io::stdout());
|
||||
output.write_all(b"\n=== MCP OAuth Client ===\n\n").await?;
|
||||
output
|
||||
.write_all(b"Please open the following URL in your browser to authorize:\n\n")
|
||||
.await?;
|
||||
output
|
||||
.write_all(oauth_state.get_authorization_url().await?.as_bytes())
|
||||
.await?;
|
||||
output
|
||||
.write_all(b"\n\nWaiting for browser callback, please do not close this window...\n")
|
||||
.await?;
|
||||
output.flush().await?;
|
||||
|
||||
// Wait for authorization code
|
||||
tracing::info!("Waiting for authorization code...");
|
||||
let auth_code = code_receiver
|
||||
.await
|
||||
.context("Failed to get authorization code")?;
|
||||
tracing::info!("Received authorization code: {}", auth_code);
|
||||
// Exchange code for access token
|
||||
tracing::info!("Exchanging authorization code for access token...");
|
||||
oauth_state
|
||||
.handle_callback(&auth_code)
|
||||
.await
|
||||
.context("Failed to handle callback")?;
|
||||
tracing::info!("Successfully obtained access token");
|
||||
|
||||
output
|
||||
.write_all(b"\nAuthorization successful! Access token obtained.\n\n")
|
||||
.await?;
|
||||
output.flush().await?;
|
||||
|
||||
// Create authorized transport, this transport is authorized by the oauth state machine
|
||||
tracing::info!("Establishing authorized connection to MCP server...");
|
||||
let transport =
|
||||
match create_authorized_transport(MCP_SSE_URL.to_string(), oauth_state, Some(retry_config))
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create authorized transport: {}", e);
|
||||
return Err(anyhow::anyhow!("Connection failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// Create client and connect to MCP server
|
||||
let client_service = ClientInfo::default();
|
||||
let client = client_service.serve(transport).await?;
|
||||
tracing::info!("Successfully connected to MCP server");
|
||||
|
||||
// Test API requests
|
||||
output
|
||||
.write_all(b"Fetching available tools from server...\n")
|
||||
.await?;
|
||||
output.flush().await?;
|
||||
|
||||
match client.peer().list_all_tools().await {
|
||||
Ok(tools) => {
|
||||
output
|
||||
.write_all(format!("Available tools: {}\n\n", tools.len()).as_bytes())
|
||||
.await?;
|
||||
for tool in tools {
|
||||
output
|
||||
.write_all(
|
||||
format!(
|
||||
"- {} ({})\n",
|
||||
tool.name,
|
||||
tool.description.unwrap_or_default()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
output
|
||||
.write_all(format!("Error fetching tools: {}\n", e).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
.write_all(b"\nFetching available prompts from server...\n")
|
||||
.await?;
|
||||
output.flush().await?;
|
||||
|
||||
match client.peer().list_all_prompts().await {
|
||||
Ok(prompts) => {
|
||||
output
|
||||
.write_all(format!("Available prompts: {}\n\n", prompts.len()).as_bytes())
|
||||
.await?;
|
||||
for prompt in prompts {
|
||||
output
|
||||
.write_all(format!("- {}\n", prompt.name).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
output
|
||||
.write_all(format!("Error fetching prompts: {}\n", e).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
.write_all(b"\nConnection established successfully. You are now authenticated with the MCP server.\n")
|
||||
.await?;
|
||||
output.flush().await?;
|
||||
|
||||
// Keep the program running, wait for user input to exit
|
||||
output.write_all(b"\nPress Enter to exit...\n").await?;
|
||||
output.flush().await?;
|
||||
|
||||
let mut input = String::new();
|
||||
let mut reader = BufReader::new(tokio::io::stdin());
|
||||
reader.read_line(&mut input).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
[package]
|
||||
name = "mcp-server-examples"
|
||||
version = "0.1.5"
|
||||
|
|
@ -7,7 +5,7 @@ edition = "2024"
|
|||
publish = false
|
||||
|
||||
[dependencies]
|
||||
rmcp= { path = "../../crates/rmcp", features = ["server", "transport-sse-server", "transport-io"] }
|
||||
rmcp= { path = "../../crates/rmcp", features = ["server", "transport-sse-server", "transport-io", "auth"] }
|
||||
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "io-std", "signal"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
|
@ -19,9 +17,15 @@ tracing-subscriber = { version = "0.3", features = [
|
|||
"fmt",
|
||||
] }
|
||||
futures = "0.3"
|
||||
rand = { version = "0.9" }
|
||||
rand = { version = "0.8", features = ["std"] }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
schemars = { version = "0.8", optional = true }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
chrono = "0.4"
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
serde_urlencoded = "0.7"
|
||||
askama = { version = "0.14"}
|
||||
|
||||
# [dev-dependencies.'cfg(target_arch="linux")'.dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
@ -43,4 +47,12 @@ path = "src/axum_router.rs"
|
|||
|
||||
[[example]]
|
||||
name = "servers_generic_server"
|
||||
path = "src/generic_service.rs"
|
||||
path = "src/generic_service.rs"
|
||||
|
||||
[[example]]
|
||||
name = "servers_auth_sse"
|
||||
path = "src/auth_sse.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mcp_oauth_server"
|
||||
path = "src/mcp_oauth_server.rs"
|
||||
183
examples/servers/src/auth_sse.rs
Normal file
183
examples/servers/src/auth_sse.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/// This example shows how to use the RMCP SSE server with OAuth authorization.
|
||||
/// Use the inspector to view this server https://github.com/modelcontextprotocol/inspector
|
||||
/// The default index page is available at http://127.0.0.1:8000/
|
||||
/// # Get a token
|
||||
/// curl http://127.0.0.1:8000/api/token/demo
|
||||
/// # Connect to SSE using the token
|
||||
/// curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8000/sse
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Html, Response},
|
||||
routing::get,
|
||||
};
|
||||
use rmcp::transport::{SseServer, sse_server::SseServerConfig};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
mod common;
|
||||
use common::counter::Counter;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
const BIND_ADDRESS: &str = "127.0.0.1:8000";
|
||||
const INDEX_HTML: &str = include_str!("html/sse_auth_index.html");
|
||||
// A simple token store
|
||||
struct TokenStore {
|
||||
valid_tokens: Vec<String>,
|
||||
}
|
||||
|
||||
impl TokenStore {
|
||||
fn new() -> Self {
|
||||
// For demonstration purposes, use more secure token management in production
|
||||
Self {
|
||||
valid_tokens: vec!["demo-token".to_string(), "test-token".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid(&self, token: &str) -> bool {
|
||||
self.valid_tokens.contains(&token.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Extract authorization token
|
||||
fn extract_token(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("Authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|auth_header| {
|
||||
auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.map(|stripped| stripped.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Authorization middleware
|
||||
async fn auth_middleware(
|
||||
State(token_store): State<Arc<TokenStore>>,
|
||||
headers: HeaderMap,
|
||||
request: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
match extract_token(&headers) {
|
||||
Some(token) if token_store.is_valid(&token) => {
|
||||
// Token is valid, proceed with the request
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
// Token is invalid, return 401 error
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root path handler
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
// Token generation endpoint (simplified example)
|
||||
async fn get_token(Path(token_id): Path<String>) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
// In a real application, you should authenticate the user and generate a real token
|
||||
if token_id == "demo" || token_id == "test" {
|
||||
let token = format!("{}-token", token_id);
|
||||
Ok(Json(serde_json::json!({
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
})))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
|
||||
#[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 token store
|
||||
let token_store = Arc::new(TokenStore::new());
|
||||
|
||||
// Set up port
|
||||
let addr = BIND_ADDRESS.parse::<SocketAddr>()?;
|
||||
|
||||
// Create SSE server configuration
|
||||
let sse_config = SseServerConfig {
|
||||
bind: addr,
|
||||
sse_path: "/sse".to_string(),
|
||||
post_path: "/message".to_string(),
|
||||
ct: CancellationToken::new(),
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
};
|
||||
|
||||
// Create SSE server
|
||||
let (sse_server, sse_router) = SseServer::new(sse_config);
|
||||
|
||||
// Create API routes
|
||||
let api_routes = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/token/{token_id}", get(get_token));
|
||||
|
||||
// Create protected SSE routes (require authorization)
|
||||
let protected_sse_router = sse_router.layer(middleware::from_fn_with_state(
|
||||
token_store.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// Create main router, public endpoints don't require authorization
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.nest("/api", api_routes)
|
||||
.merge(protected_sse_router)
|
||||
.with_state(());
|
||||
|
||||
// Start server and register service
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let ct = sse_server.config.ct.clone();
|
||||
|
||||
// Start SSE server with Counter service
|
||||
sse_server.with_service(Counter::new);
|
||||
|
||||
// Handle signals for graceful shutdown
|
||||
let cancel_token = ct.clone();
|
||||
tokio::spawn(async move {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
println!("Received Ctrl+C, shutting down server...");
|
||||
cancel_token.cancel();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Unable to listen for Ctrl+C signal: {}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
tracing::info!("Server started on {}", addr);
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||
// Wait for cancellation signal
|
||||
ct.cancelled().await;
|
||||
println!("Server is shutting down...");
|
||||
});
|
||||
|
||||
if let Err(e) = server.await {
|
||||
eprintln!("Server error: {}", e);
|
||||
}
|
||||
|
||||
println!("Server has been shut down");
|
||||
Ok(())
|
||||
}
|
||||
64
examples/servers/src/html/mcp_oauth_index.html
Normal file
64
examples/servers/src/html/mcp_oauth_index.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP OAuth Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px auto; max-width: 800px; line-height: 1.6; }
|
||||
h1, h2 { color: #333; }
|
||||
code { background: #f4f4f4; padding: 2px 5px; border-radius: 3px; }
|
||||
.endpoint { background: #f9f9f9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
|
||||
.flow { background: #e8f5e9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MCP OAuth Server</h1>
|
||||
<p>This is an MCP server with OAuth 2.0 integration to a third-party authorization server.</p>
|
||||
|
||||
<h2>Available Endpoints:</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>Authorization Endpoint</h3>
|
||||
<p><code>GET /oauth/authorize</code></p>
|
||||
<p>Parameters:</p>
|
||||
<ul>
|
||||
<li><code>response_type</code> - Must be "code"</li>
|
||||
<li><code>client_id</code> - Client identifier (e.g., "mcp-client")</li>
|
||||
<li><code>redirect_uri</code> - URI to redirect after authorization</li>
|
||||
<li><code>scope</code> - Optional requested scope</li>
|
||||
<li><code>state</code> - Optional state value for CSRF prevention</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>Token Endpoint</h3>
|
||||
<p><code>POST /oauth/token</code></p>
|
||||
<p>Parameters:</p>
|
||||
<ul>
|
||||
<li><code>grant_type</code> - Must be "authorization_code"</li>
|
||||
<li><code>code</code> - The authorization code</li>
|
||||
<li><code>client_id</code> - Client identifier</li>
|
||||
<li><code>client_secret</code> - Client secret</li>
|
||||
<li><code>redirect_uri</code> - Redirect URI used in authorization request</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>MCP SSE Endpoints</h3>
|
||||
<p><code>/mcp/sse</code> - SSE connection endpoint (requires OAuth token)</p>
|
||||
<p><code>/mcp/message</code> - Message endpoint (requires OAuth token)</p>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<h2>OAuth Flow:</h2>
|
||||
<ol>
|
||||
<li>MCP Client initiates OAuth flow with this MCP Server</li>
|
||||
<li>MCP Server redirects to Third-Party OAuth Server</li>
|
||||
<li>User authenticates with Third-Party Server</li>
|
||||
<li>Third-Party Server redirects back to MCP Server with auth code</li>
|
||||
<li>MCP Server exchanges the code for a third-party access token</li>
|
||||
<li>MCP Server generates its own token bound to the third-party session</li>
|
||||
<li>MCP Server completes the OAuth flow with the MCP Client</li>
|
||||
</ol>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
examples/servers/src/html/sse_auth_index.html
Normal file
33
examples/servers/src/html/sse_auth_index.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>RMCP Authorized SSE Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
|
||||
h1 { color: #333; }
|
||||
.endpoint { background: #f4f4f4; padding: 10px; border-radius: 5px; }
|
||||
pre { background: #eee; padding: 10px; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RMCP Authorized SSE Server</h1>
|
||||
<p>This is a Server-Sent Events server example that requires OAuth authorization.</p>
|
||||
|
||||
<h2>Available Endpoints:</h2>
|
||||
<ul>
|
||||
<li class="endpoint"><code>/api/health</code> - Health check</li>
|
||||
<li class="endpoint"><code>/api/token/{token_id}</code> - Get test token (available: demo, test)</li>
|
||||
<li class="endpoint"><code>/sse</code> - SSE connection endpoint (requires authorization)</li>
|
||||
<li class="endpoint"><code>/message</code> - Message sending endpoint (requires authorization)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Usage:</h2>
|
||||
<pre>
|
||||
# Get a token
|
||||
curl http://127.0.0.1:8000/api/token/demo
|
||||
|
||||
# Connect to SSE using the token
|
||||
curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8000/sse
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
703
examples/servers/src/mcp_oauth_server.rs
Normal file
703
examples/servers/src/mcp_oauth_server.rs
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
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::{Rng, distributions::Alphanumeric};
|
||||
use rmcp::transport::{
|
||||
SseServer,
|
||||
auth::{
|
||||
AuthorizationMetadata, ClientRegistrationRequest, ClientRegistrationResponse,
|
||||
OAuthClientConfig,
|
||||
},
|
||||
sse_server::SseServerConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
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");
|
||||
|
||||
// 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 {
|
||||
client_id: "mcp-client".to_string(),
|
||||
client_secret: Some("mcp-client-secret".to_string()),
|
||||
scopes: vec!["profile".to_string(), "email".to_string()],
|
||||
redirect_uri: "http://localhost:8080/callback".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) {
|
||||
if client.redirect_uri.contains(&redirect_uri.to_string()) {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct UserInfo {
|
||||
sub: String,
|
||||
name: String,
|
||||
email: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
fn generate_random_string(length: usize) -> String {
|
||||
rand::thread_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(¶ms.client_id, ¶ms.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 SSE 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 metadata = AuthorizationMetadata {
|
||||
authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS),
|
||||
token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS),
|
||||
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
|
||||
registration_endpoint: format!("http://{}/oauth/register", BIND_ADDRESS),
|
||||
issuer: Some(BIND_ADDRESS.to_string()),
|
||||
jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)),
|
||||
};
|
||||
debug!("metadata: {:?}", metadata);
|
||||
(StatusCode::OK, Json(metadata))
|
||||
}
|
||||
|
||||
// handle client registration request
|
||||
async fn oauth_register(
|
||||
State(state): State<Arc<McpOAuthStore>>,
|
||||
Json(req): Json<ClientRegistrationRequest>,
|
||||
) -> 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 {
|
||||
client_id: client_id.clone(),
|
||||
client_secret: Some(client_secret.clone()),
|
||||
redirect_uri: req.redirect_uris[0].clone(),
|
||||
scopes: vec![],
|
||||
};
|
||||
|
||||
state
|
||||
.clients
|
||||
.write()
|
||||
.await
|
||||
.insert(client_id.clone(), client);
|
||||
|
||||
// return client information
|
||||
let response = ClientRegistrationResponse {
|
||||
client_id,
|
||||
client_secret,
|
||||
client_name: req.client_name,
|
||||
redirect_uris: req.redirect_uris,
|
||||
};
|
||||
|
||||
(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 SSE server configuration for MCP
|
||||
let sse_config = SseServerConfig {
|
||||
bind: addr,
|
||||
sse_path: "/mcp/sse".to_string(),
|
||||
post_path: "/mcp/message".to_string(),
|
||||
ct: CancellationToken::new(),
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
};
|
||||
|
||||
// Create SSE server
|
||||
let (sse_server, sse_router) = SseServer::new(sse_config);
|
||||
|
||||
// Create protected SSE routes (require authorization)
|
||||
let protected_sse_router = sse_router.layer(middleware::from_fn_with_state(
|
||||
oauth_store.clone(),
|
||||
validate_token_middleware,
|
||||
));
|
||||
|
||||
// Create HTTP router with request logging middleware
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/mcp", get(index))
|
||||
.route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
get(oauth_authorization_server),
|
||||
)
|
||||
.route("/oauth/authorize", get(oauth_authorize))
|
||||
.route("/oauth/approve", post(oauth_approve))
|
||||
.route("/oauth/token", post(oauth_token))
|
||||
.route("/oauth/register", post(oauth_register))
|
||||
// .merge(protected_sse_router)
|
||||
.with_state(oauth_store.clone())
|
||||
.layer(middleware::from_fn(log_request));
|
||||
|
||||
let app = app.merge(protected_sse_router);
|
||||
// Register token validation middleware for SSE
|
||||
let cancel_token = sse_server.config.ct.clone();
|
||||
// Handle Ctrl+C
|
||||
let cancel_token2 = sse_server.config.ct.clone();
|
||||
// Start SSE server with Counter service
|
||||
sse_server.with_service(Counter::new);
|
||||
|
||||
// Start HTTP server
|
||||
info!("MCP OAuth Server started on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||
cancel_token.cancelled().await;
|
||||
info!("Server is shutting down");
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
info!("Received Ctrl+C, shutting down");
|
||||
cancel_token2.cancel();
|
||||
}
|
||||
Err(e) => error!("Failed to listen for Ctrl+C: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = server.await {
|
||||
error!("Server error: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
106
examples/servers/templates/mcp_oauth_authorize.html
Normal file
106
examples/servers/templates/mcp_oauth_authorize.html
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP OAuth</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #f1f1f1;
|
||||
--text-color: #333;
|
||||
--border-color: #ddd;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f8f9fa;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
background: var(--secondary-color);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #3367d6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>MCP OAuth</h1>
|
||||
<div class="client-info">
|
||||
<p><strong>{{ client_id }}</strong> requests access to your account.</p>
|
||||
<p>requested scopes: {{ scopes }}</p>
|
||||
</div>
|
||||
|
||||
<form action="/oauth/approve" method="post">
|
||||
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="state" value="{{ state }}">
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" name="approved" value="true" class="btn btn-primary">Approve</button>
|
||||
<button type="submit" name="approved" value="false" class="btn btn-secondary">Reject</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue