* fix(model): deprecate ServerInfo and ClientInfo aliases Those names collide with the protocol's serverInfo/clientInfo fields, which are only Implementation identity. Prefer InitializeResult and InitializeRequestParams. Handler trait signatures, the tool_handler macro output, tests, examples and docs now use the canonical types; the aliases remain as deprecated shims for the rest of 3.x. Fixes #1082 * fix(model): keep public signatures on the deprecated aliases The public API check compares rendered signatures, and rustdoc records a type alias by the name it was written with. Respelling `ServerHandler::get_info`, `ClientHandler::get_info`, `DiscoverResult::from_server_info` and `impl ClientHandler for ClientInfo` onto `InitializeResult`/`InitializeRequestParams` therefore reads as changed public items even though the aliases resolve to those exact types and nothing downstream breaks. Put those four signatures back on the aliases so the published API is byte-identical, and note why at the alias definitions. The deprecation still steers new code, and the macro output, tests, examples and docs keep using the canonical names. * fix(model): migrate leftover ClientInfo test sites Two tests landed on main after the alias deprecation and still named ClientInfo. Clippy -D warnings and the no-local feature test job both failed on them.
405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
#![cfg(not(feature = "local"))]
|
|
use rmcp::{
|
|
ErrorData, ServerHandler,
|
|
model::{
|
|
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult,
|
|
ProgressNotificationParam, ServerCapabilities,
|
|
},
|
|
service::RequestContext,
|
|
transport::streamable_http_server::{
|
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
|
},
|
|
};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
mod common;
|
|
use common::calculator::Calculator;
|
|
|
|
const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#;
|
|
const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#;
|
|
const NEGOTIATED_CALL_BODY: &str = r#"{
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "terminal",
|
|
"arguments": {},
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": {
|
|
"name": "test",
|
|
"version": "1.0"
|
|
},
|
|
"io.modelcontextprotocol/clientCapabilities": {}
|
|
}
|
|
}
|
|
}"#;
|
|
const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "progress",
|
|
"arguments": {},
|
|
"_meta": {
|
|
"progressToken": "progress-test-1",
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": {
|
|
"name": "test",
|
|
"version": "1.0"
|
|
},
|
|
"io.modelcontextprotocol/clientCapabilities": {}
|
|
}
|
|
}
|
|
}"#;
|
|
|
|
#[derive(Clone)]
|
|
struct ProgressServer;
|
|
|
|
impl ServerHandler for ProgressServer {
|
|
fn get_info(&self) -> InitializeResult {
|
|
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
|
}
|
|
|
|
async fn call_tool(
|
|
&self,
|
|
request: CallToolRequestParams,
|
|
context: RequestContext<rmcp::RoleServer>,
|
|
) -> Result<CallToolResponse, ErrorData> {
|
|
if request.name == "progress" {
|
|
let progress_token = context
|
|
.meta
|
|
.get_progress_token()
|
|
.expect("request includes progressToken");
|
|
context
|
|
.peer
|
|
.notify_progress(
|
|
ProgressNotificationParam::new(progress_token, 50.0)
|
|
.with_total(100.0)
|
|
.with_message("working"),
|
|
)
|
|
.await
|
|
.expect("progress notification is delivered");
|
|
}
|
|
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
|
|
}
|
|
}
|
|
|
|
async fn spawn_server(
|
|
config: StreamableHttpServerConfig,
|
|
) -> (reqwest::Client, String, CancellationToken) {
|
|
let ct = config.cancellation_token.clone();
|
|
let service: StreamableHttpService<Calculator, LocalSessionManager> =
|
|
StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config);
|
|
|
|
let router = axum::Router::new().nest_service("/mcp", service);
|
|
let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = tcp_listener.local_addr().unwrap();
|
|
|
|
tokio::spawn({
|
|
let ct = ct.clone();
|
|
async move {
|
|
let _ = axum::serve(tcp_listener, router)
|
|
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
|
|
.await;
|
|
}
|
|
});
|
|
|
|
let client = reqwest::Client::new();
|
|
let base_url = format!("http://{addr}/mcp");
|
|
(client, base_url, ct)
|
|
}
|
|
|
|
async fn spawn_progress_server(
|
|
config: StreamableHttpServerConfig,
|
|
) -> (reqwest::Client, String, CancellationToken) {
|
|
let ct = config.cancellation_token.clone();
|
|
let service: StreamableHttpService<ProgressServer, LocalSessionManager> =
|
|
StreamableHttpService::new(|| Ok(ProgressServer), Default::default(), config);
|
|
|
|
let router = axum::Router::new().nest_service("/mcp", service);
|
|
let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = tcp_listener.local_addr().unwrap();
|
|
|
|
tokio::spawn({
|
|
let ct = ct.clone();
|
|
async move {
|
|
let _ = axum::serve(tcp_listener, router)
|
|
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
|
|
.await;
|
|
}
|
|
});
|
|
|
|
let client = reqwest::Client::new();
|
|
let base_url = format!("http://{addr}/mcp");
|
|
(client, base_url, ct)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
let (client, url, ct) = spawn_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(INIT_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("application/json"),
|
|
"Expected application/json, got: {content_type}"
|
|
);
|
|
|
|
let body = response.text().await?;
|
|
let parsed: serde_json::Value = serde_json::from_str(&body)?;
|
|
assert_eq!(parsed["jsonrpc"], "2.0");
|
|
assert_eq!(parsed["id"], 1);
|
|
assert!(parsed["result"].is_object(), "Expected result object");
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_negotiated_terminal_response_returns_application_json() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
let (client, url, ct) = spawn_progress_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2026-07-28")
|
|
.header("Mcp-Method", "tools/call")
|
|
.header("Mcp-Name", "terminal")
|
|
.body(NEGOTIATED_CALL_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("application/json"),
|
|
"Expected application/json, got: {content_type}"
|
|
);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(body["id"], 2);
|
|
assert!(body["result"].is_object(), "Expected result object");
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
let (client, url, ct) = spawn_progress_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(CALL_WITH_PROGRESS_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("text/event-stream"),
|
|
"Expected SSE fallback, got: {content_type}"
|
|
);
|
|
|
|
let body = response.text().await?;
|
|
let messages: Vec<serde_json::Value> = body
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("data:"))
|
|
.map(str::trim)
|
|
.filter(|data| !data.is_empty())
|
|
.map(serde_json::from_str)
|
|
.collect::<Result<_, _>>()?;
|
|
assert_eq!(messages.len(), 2, "Expected progress and result: {body}");
|
|
assert_eq!(messages[0]["method"], "notifications/progress");
|
|
assert_eq!(messages[1]["id"], 2);
|
|
assert!(
|
|
messages[1]["result"].is_object(),
|
|
"Expected result object: {body}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
let (client, url, ct) = spawn_progress_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2026-07-28")
|
|
.header("Mcp-Method", "tools/call")
|
|
.header("Mcp-Name", "progress")
|
|
.body(NEGOTIATED_CALL_WITH_PROGRESS_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("text/event-stream"),
|
|
"Expected SSE fallback, got: {content_type}"
|
|
);
|
|
|
|
let body = response.text().await?;
|
|
let messages: Vec<serde_json::Value> = body
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("data:"))
|
|
.map(str::trim)
|
|
.filter(|data| !data.is_empty())
|
|
.map(serde_json::from_str)
|
|
.collect::<Result<_, _>>()?;
|
|
assert_eq!(messages.len(), 2, "Expected progress and result: {body}");
|
|
assert_eq!(messages[0]["method"], "notifications/progress");
|
|
assert_eq!(messages[1]["id"], 2);
|
|
assert!(
|
|
messages[1]["result"].is_object(),
|
|
"Expected result object: {body}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
let (client, url, ct) = spawn_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(INIT_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("text/event-stream"),
|
|
"Expected text/event-stream, got: {content_type}"
|
|
);
|
|
|
|
let body = response.text().await?;
|
|
assert!(
|
|
body.contains("data:"),
|
|
"Expected SSE framing (data: prefix), got: {body}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> {
|
|
let ct = CancellationToken::new();
|
|
// json_response: true has no effect when legacy_session_mode: true — server still uses SSE
|
|
let (client, url, ct) = spawn_server(
|
|
StreamableHttpServerConfig::default()
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(ct.child_token()),
|
|
)
|
|
.await;
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(INIT_BODY)
|
|
.send()
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("text/event-stream"),
|
|
"Stateful mode should always use SSE regardless of json_response, got: {content_type}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|