rmcp 3.6.0

Rust SDK for Model Context Protocol
Documentation
#![cfg(all(
    not(feature = "local"),
    feature = "client",
    feature = "reqwest",
    feature = "transport-streamable-http-server"
))]
#![expect(deprecated, reason = "This test exercises the deprecated sampling API")]

use std::{borrow::Cow, sync::Arc, time::Duration};

use rmcp::{
    ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler,
    model::{
        CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
        CreateMessageRequestParams, CreateMessageResult, ErrorCode, ProtocolVersion,
        SamplingMessage, ServerCapabilities, ServerConfig,
    },
    service::{RequestContext, RoleClient, RoleServer},
    transport::{
        StreamableHttpClientTransport,
        streamable_http_client::StreamableHttpClientTransportConfig,
        streamable_http_server::{
            StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
        },
    },
};
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;

#[derive(Clone)]
struct SamplingServer;

impl ServerHandler for SamplingServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
    }

    fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
        Cow::Borrowed(&[ProtocolVersion::V_2026_07_28])
    }

    async fn call_tool(
        &self,
        _request: CallToolRequestParams,
        context: RequestContext<RoleServer>,
    ) -> Result<CallToolResponse, rmcp::ErrorData> {
        context
            .peer
            .create_message(CreateMessageRequestParams::new(
                vec![SamplingMessage::user_text("Should we proceed?")],
                64,
            ))
            .await
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
    }
}

#[derive(Clone)]
struct AnsweringClient;

impl ClientHandler for AnsweringClient {
    async fn create_message(
        &self,
        _params: CreateMessageRequestParams,
        _context: RequestContext<RoleClient>,
    ) -> Result<CreateMessageResult, rmcp::ErrorData> {
        Ok(CreateMessageResult::new(
            SamplingMessage::assistant_text("yes"),
            "test-model".to_string(),
        ))
    }
}

async fn spawn_server(ct: &CancellationToken) -> String {
    let service = StreamableHttpService::new(
        || Ok(SamplingServer),
        Arc::new(LocalSessionManager::default()),
        StreamableHttpServerConfig::default()
            .with_sse_keep_alive(None)
            .with_cancellation_token(ct.child_token()),
    );
    let router = axum::Router::new().nest_service("/mcp", service);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("listener should bind");
    let address = listener.local_addr().expect("listener address");
    let ct = ct.clone();
    tokio::spawn(async move {
        let _ = axum::serve(listener, router)
            .with_graceful_shutdown(async move { ct.cancelled_owned().await })
            .await;
    });
    format!("http://{address}/mcp")
}

#[tokio::test]
async fn sampling_from_stateless_handler_fails_instead_of_hanging() {
    let ct = CancellationToken::new();
    let url = spawn_server(&ct).await;
    let client = AnsweringClient
        .serve_with_lifecycle(
            StreamableHttpClientTransport::from_config(
                StreamableHttpClientTransportConfig::with_uri(url),
            ),
            ClientLifecycleMode::Discover {
                preferred_versions: vec![ProtocolVersion::V_2026_07_28],
            },
        )
        .await
        .expect("discover should succeed");

    let outcome = tokio::time::timeout(
        Duration::from_secs(5),
        client.call_tool(CallToolRequestParams::new("ask")),
    )
    .await
    .expect("tool call must not hang");

    let error = outcome.expect_err("sampling must be rejected on 2026-07-28");
    assert!(
        error.to_string().contains("InputRequiredResult"),
        "unexpected error: {error}"
    );
    client.cancel().await.expect("cancel client");
    ct.cancel();
}

#[tokio::test]
async fn stateless_server_rejects_posted_response() {
    let ct = CancellationToken::new();
    let url = spawn_server(&ct).await;

    let response = reqwest::Client::new()
        .post(&url)
        .header("Content-Type", "application/json")
        .header("Accept", "application/json, text/event-stream")
        .header("MCP-Protocol-Version", "2026-07-28")
        .json(&json!({ "jsonrpc": "2.0", "id": 0, "result": {} }))
        .send()
        .await
        .expect("send response POST");

    assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
    let body: Value = response.json().await.expect("JSON-RPC error body");
    assert_eq!(
        body["error"]["code"],
        json!(ErrorCode::INVALID_REQUEST.0),
        "unexpected body: {body}"
    );
    ct.cancel();
}