rmcp 3.9.0

Rust SDK for Model Context Protocol
Documentation
use std::{
    any::TypeId,
    collections::VecDeque,
    error::Error,
    sync::{Arc, Mutex},
};

use rmcp::{
    service::ClientInitializeError,
    transport::{
        AuthError, DynamicTransportError,
        auth::{
            AuthorizationManager, OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture,
            OAuthHttpRequest, ProtectedResourceMetadataInvalidReason,
        },
        streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError},
    },
};
use thiserror::Error;

type TestHttpError = StreamableHttpError<std::io::Error>;

#[derive(Debug, Error)]
#[error("outer transport wrapper")]
struct OuterError(#[source] TestHttpError);

fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError {
    ClientInitializeError::TransportError {
        error: DynamicTransportError::from_parts(
            "test transport",
            TypeId::of::<()>(),
            Box::new(error),
        ),
        context: "initialize".into(),
    }
}

#[test]
fn classifies_local_authorization_required() {
    let error = TestHttpError::Auth(AuthError::AuthorizationRequired);

    assert!(initialization_error(error).is_authorization_required());
}

#[test]
fn classifies_http_authorization_challenge() {
    let error =
        TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));

    assert!(initialization_error(error).is_authorization_required());
}

#[test]
fn classifies_authorization_required_through_multiple_sources() {
    let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired));

    assert!(initialization_error(error).is_authorization_required());
}

#[test]
fn does_not_classify_unrelated_transport_errors() {
    let closed = TestHttpError::TransportChannelClosed;
    let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned()));
    let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new(
        "Bearer error=\"insufficient_scope\"".to_owned(),
        Some("admin".to_owned()),
    ));

    assert!(!initialization_error(closed).is_authorization_required());
    assert!(!initialization_error(refresh).is_authorization_required());
    assert!(!initialization_error(scope).is_authorization_required());
}

#[test]
fn does_not_classify_non_transport_initialization_errors() {
    assert!(!ClientInitializeError::Cancelled.is_authorization_required());
    assert!(
        !ClientInitializeError::ConnectionClosed("server closed the connection".to_owned())
            .is_authorization_required()
    );
}

#[test]
fn http_challenge_remains_available_as_an_error_source() {
    let error =
        TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));

    let source = error.source().expect("auth challenge should be a source");
    let challenge = source
        .downcast_ref::<AuthRequiredError>()
        .expect("source should retain the challenge type");

    assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\"");
}

struct DiscoveryHttpClient(Mutex<VecDeque<Result<oauth2::HttpResponse, OAuthHttpClientError>>>);

impl OAuthHttpClient for DiscoveryHttpClient {
    fn execute(&self, _request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
        Box::pin(async {
            self.0
                .lock()
                .unwrap()
                .pop_front()
                .expect("unexpected discovery request")
        })
    }
}

fn discovery_response(status: u16, body: serde_json::Value) -> oauth2::HttpResponse {
    oauth2::http::Response::builder()
        .status(status)
        .body(serde_json::to_vec(&body).unwrap())
        .unwrap()
}

async fn protected_resource_discovery_error(
    responses: Vec<Result<oauth2::HttpResponse, OAuthHttpClientError>>,
) -> AuthError {
    let client = DiscoveryHttpClient(Mutex::new(responses.into()));
    let manager = AuthorizationManager::new_with_oauth_http_client(
        "https://mcp.example.com/mcp",
        Arc::new(client),
    )
    .await
    .unwrap();

    manager
        .resolve_metadata_from_challenge(Some(
            r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
        ))
        .await
        .unwrap_err()
}

#[derive(Debug, Error)]
#[error("discovery request failed")]
struct DiscoveryRequestError(#[source] std::io::Error);

#[tokio::test]
async fn protected_resource_discovery_request_failure_has_a_structured_error_and_source() {
    let error = protected_resource_discovery_error(vec![Err(Box::new(DiscoveryRequestError(
        std::io::Error::other("connection refused"),
    )))])
    .await;

    assert!(
        matches!(
            &error,
            AuthError::DiscoveryRequestFailed { url, .. }
                if url.as_str() == "https://mcp.example.com/.well-known/oauth-protected-resource"
        ),
        "expected structured discovery request error, got {error:?}"
    );
    let source = error
        .source()
        .expect("discovery request error should retain its source");
    let request_error = source
        .downcast_ref::<DiscoveryRequestError>()
        .expect("source should retain the OAuth HTTP client error type");
    assert_eq!(
        request_error
            .source()
            .expect("nested I/O source should be retained")
            .to_string(),
        "connection refused"
    );
}

#[tokio::test]
async fn protected_resource_invalid_metadata_has_a_structured_error() {
    let error = protected_resource_discovery_error(vec![Ok(discovery_response(
        200,
        serde_json::json!({"authorization_servers": ["https://auth.example.com"]}),
    ))])
    .await;

    assert!(matches!(
        &error,
        AuthError::ProtectedResourceMetadataInvalid {
            url,
            reason
        } if url.as_str() == "https://mcp.example.com/.well-known/oauth-protected-resource"
            && matches!(reason.as_ref(), ProtectedResourceMetadataInvalidReason::MissingResource)
    ));
}

#[tokio::test]
async fn protected_resource_unavailable_authorization_servers_have_a_structured_error() {
    let error = protected_resource_discovery_error(vec![
        Ok(discovery_response(
            200,
            serde_json::json!({
                "resource": "https://mcp.example.com/mcp",
                "authorization_servers": ["https://auth.example.com"]
            }),
        )),
        Ok(discovery_response(404, serde_json::json!({}))),
        Ok(discovery_response(404, serde_json::json!({}))),
    ])
    .await;

    assert!(matches!(
        error,
        AuthError::AuthorizationServersUnavailable {
            resource_metadata_url,
            authorization_servers
        } if resource_metadata_url.as_str()
                == "https://mcp.example.com/.well-known/oauth-protected-resource"
            && authorization_servers == ["https://auth.example.com"]
    ));
}