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, ProtocolVersion, ServerCapabilities, ServerConfig},
|
|
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) -> ServerConfig {
|
|
ServerConfig::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();
|
|
}
|