* 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.
380 lines
12 KiB
Rust
380 lines
12 KiB
Rust
#![cfg(all(
|
|
not(feature = "local"),
|
|
feature = "reqwest",
|
|
feature = "transport-streamable-http-server"
|
|
))]
|
|
|
|
use std::borrow::Cow;
|
|
|
|
use rmcp::{
|
|
ServerHandler,
|
|
model::{Implementation, InitializeResult, ProtocolVersion, ServerCapabilities},
|
|
transport::streamable_http_server::{
|
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
|
},
|
|
};
|
|
use serde_json::json;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct DiscoveryServer;
|
|
|
|
impl ServerHandler for DiscoveryServer {
|
|
fn get_info(&self) -> InitializeResult {
|
|
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
|
.with_server_info(Implementation::new("discovery-server", "1.0.0"))
|
|
.with_instructions("Use the tools carefully")
|
|
}
|
|
|
|
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
|
Cow::Borrowed(&[ProtocolVersion::V_2025_11_25])
|
|
}
|
|
}
|
|
|
|
async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) {
|
|
spawn_server_with_legacy_session_mode(json_response, false).await
|
|
}
|
|
|
|
async fn spawn_server_with_legacy_session_mode(
|
|
json_response: bool,
|
|
legacy_session_mode: bool,
|
|
) -> (reqwest::Client, String, CancellationToken) {
|
|
let cancellation_token = CancellationToken::new();
|
|
let config = StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(legacy_session_mode)
|
|
.with_json_response(json_response)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(cancellation_token.clone());
|
|
let service: StreamableHttpService<DiscoveryServer, LocalSessionManager> =
|
|
StreamableHttpService::new(|| Ok(DiscoveryServer), Default::default(), config);
|
|
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 should have address");
|
|
|
|
tokio::spawn({
|
|
let cancellation_token = cancellation_token.clone();
|
|
async move {
|
|
let _ = axum::serve(listener, router)
|
|
.with_graceful_shutdown(async move {
|
|
cancellation_token.cancelled_owned().await;
|
|
})
|
|
.await;
|
|
}
|
|
});
|
|
|
|
(
|
|
reqwest::Client::new(),
|
|
format!("http://{address}/mcp"),
|
|
cancellation_token,
|
|
)
|
|
}
|
|
|
|
fn discover_body(version: Option<&str>) -> serde_json::Value {
|
|
let meta = version.map(|version| {
|
|
json!({
|
|
"io.modelcontextprotocol/protocolVersion": version,
|
|
"io.modelcontextprotocol/clientInfo": {
|
|
"name": "test-client",
|
|
"version": "1.0.0"
|
|
},
|
|
"io.modelcontextprotocol/clientCapabilities": {}
|
|
})
|
|
});
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "server/discover",
|
|
"params": meta.map(|meta| json!({ "_meta": meta })).unwrap_or_else(|| json!({}))
|
|
})
|
|
}
|
|
|
|
async fn post_discover(
|
|
client: &reqwest::Client,
|
|
url: &str,
|
|
header_version: &str,
|
|
body_version: Option<&str>,
|
|
) -> reqwest::Response {
|
|
client
|
|
.post(url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", header_version)
|
|
.header("Mcp-Method", "server/discover")
|
|
.json(&discover_body(body_version))
|
|
.send()
|
|
.await
|
|
.expect("discover request should send")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_returns_server_metadata_without_session() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
|
|
let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(
|
|
body["result"],
|
|
json!({
|
|
"resultType": "complete",
|
|
"supportedVersions": ["2025-11-25"],
|
|
"capabilities": { "tools": {} },
|
|
"_meta": {
|
|
"io.modelcontextprotocol/serverInfo": {
|
|
"name": "discovery-server",
|
|
"version": "1.0.0"
|
|
}
|
|
},
|
|
"instructions": "Use the tools carefully",
|
|
"ttlMs": 0,
|
|
"cacheScope": "private"
|
|
})
|
|
);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_does_not_require_initialization_in_legacy_session_mode() {
|
|
let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await;
|
|
|
|
let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
assert!(response.headers().get("Mcp-Session-Id").is_none());
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_rejects_unsupported_version_with_http_400() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
|
|
let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await;
|
|
|
|
assert_eq!(response.status(), 400);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32022);
|
|
assert_eq!(
|
|
body["error"]["data"],
|
|
json!({
|
|
"requested": "2026-07-28",
|
|
"supported": ["2025-11-25"]
|
|
})
|
|
);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_rejects_unknown_version_with_typed_error() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
|
|
let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await;
|
|
|
|
assert_eq!(response.status(), 400);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32022);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn regular_request_rejects_server_unsupported_meta_version() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/list",
|
|
"params": {
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28"
|
|
}
|
|
}
|
|
});
|
|
|
|
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/list")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should send");
|
|
|
|
assert_eq!(response.status(), 400);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32022);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_rpc_uses_http_404_for_per_request_protocol() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 3,
|
|
"method": "unknown/method",
|
|
"params": {
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2025-11-25"
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2025-11-25")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should send");
|
|
|
|
assert_eq!(response.status(), 404);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_unknown_rpc_preserves_http_200_jsonrpc_error() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 5,
|
|
"method": "unknown/method",
|
|
"params": {}
|
|
});
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2025-11-25")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should send");
|
|
|
|
assert_eq!(response.status(), 200);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32601);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_rejects_header_meta_version_mismatch() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
|
|
let response = post_discover(&client, &url, "2026-07-28", Some("2025-11-25")).await;
|
|
|
|
assert_eq!(response.status(), 400);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_rejects_missing_request_meta() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
|
|
let response = post_discover(&client, &url, "2025-11-25", None).await;
|
|
|
|
assert_eq!(response.status(), 400);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32602);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_rejects_missing_client_capabilities() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 4,
|
|
"method": "server/discover",
|
|
"params": {
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2025-11-25",
|
|
"io.modelcontextprotocol/clientInfo": {
|
|
"name": "test-client",
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2025-11-25")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should send");
|
|
|
|
assert_eq!(response.status(), 400);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert_eq!(body["error"]["code"], -32602);
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_accepts_missing_optional_client_info() {
|
|
let (client, url, cancellation_token) = spawn_server(true).await;
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 5,
|
|
"method": "server/discover",
|
|
"params": {
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2025-11-25",
|
|
"io.modelcontextprotocol/clientCapabilities": {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("MCP-Protocol-Version", "2025-11-25")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should send");
|
|
|
|
assert_eq!(response.status(), 200);
|
|
let body: serde_json::Value = response.json().await.expect("response should be JSON");
|
|
assert!(body.get("result").is_some());
|
|
|
|
cancellation_token.cancel();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_error_uses_http_400_when_sse_is_configured() {
|
|
let (client, url, cancellation_token) = spawn_server(false).await;
|
|
|
|
let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await;
|
|
|
|
assert_eq!(response.status(), 400);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("Content-Type")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("application/json")
|
|
);
|
|
|
|
cancellation_token.cancel();
|
|
}
|