cloudiful-server 0.2.5

Rust web server bootstrap crate with Actix and Axum adapters
Documentation
use axum::{
    Router,
    body::Body,
    http::{Request, StatusCode},
};
use rmcp::ServiceExt;
use tower::ServiceExt as TowerServiceExt;

use crate::{
    ServerConfig as HttpServerConfig,
    mcp::{Server, serve, tool, tool_router},
};

#[derive(Clone)]
struct PingServer;

#[tool_router(server_handler)]
impl PingServer {
    #[tool(description = "Ping the server")]
    fn ping(&self) -> String {
        "pong".to_string()
    }
}

#[tokio::test]
async fn serve_over_async_rw_transport_exposes_tools() {
    let (server_io, client_io) = tokio::io::duplex(1024);
    let (server_read, server_write) = tokio::io::split(server_io);
    let (client_read, client_write) = tokio::io::split(client_io);

    let server_task =
        tokio::spawn(async move { serve(PingServer, (server_read, server_write)).await });

    let client = ().serve((client_read, client_write)).await.unwrap();
    let tools = client.peer().list_tools(Default::default()).await.unwrap();

    assert_eq!(tools.tools.len(), 1);
    assert_eq!(tools.tools[0].name, "ping");

    client.cancel().await.unwrap();
    server_task.await.unwrap().unwrap().cancel().await.unwrap();
}

#[tokio::test]
async fn streamable_http_server_binds_on_loopback() {
    let config = HttpServerConfig::new()
        .with_listen_addr("127.0.0.1:0")
        .build()
        .unwrap();

    let bound = Server::new(config, || PingServer)
        .with_server_config(crate::mcp::ServerConfig::new().with_service_path("/mcp"))
        .bind()
        .unwrap();

    assert!(bound.addrs().iter().all(|addr| addr.ip().is_loopback()));
}

#[tokio::test]
async fn service_path_is_normalized_for_http_router() {
    let response = crate::mcp::router(
        crate::mcp::ServerConfig::new().with_service_path("mcp/"),
        || PingServer,
    )
    .unwrap()
    .oneshot(
        Request::builder()
            .uri("/mcp")
            .header("host", "not-loopback.example")
            .body(Body::empty())
            .unwrap(),
    )
    .await
    .unwrap();

    assert_ne!(response.status(), StatusCode::NOT_FOUND);
    assert_ne!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn root_service_path_is_accepted() {
    let response = crate::mcp::router(
        crate::mcp::ServerConfig::new().with_service_path("/"),
        || PingServer,
    )
    .unwrap()
    .oneshot(
        Request::builder()
            .uri("/")
            .header("host", "not-loopback.example")
            .body(Body::empty())
            .unwrap(),
    )
    .await
    .unwrap();

    assert_ne!(response.status(), StatusCode::NOT_FOUND);
    assert_ne!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn service_can_be_nested_in_existing_router() {
    let service = crate::mcp::service(crate::mcp::ServerConfig::new(), || PingServer).unwrap();
    let app = Router::new().nest_service("/mcp", service);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/mcp")
                .header("host", "not-loopback.example")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_ne!(response.status(), StatusCode::NOT_FOUND);
    assert_ne!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn empty_service_path_is_rejected() {
    let config = HttpServerConfig::new()
        .with_listen_addr("127.0.0.1:0")
        .build()
        .unwrap();

    let err = Server::new(config, || PingServer)
        .with_server_config(crate::mcp::ServerConfig::new().with_service_path(" "))
        .bind()
        .unwrap_err();

    assert!(matches!(
        err,
        crate::mcp::McpServerError::InvalidServicePath(_)
    ));
}