feat!: add server discovery and negotiation (SEP-2575) (#973)

* feat!: add server discovery and negotiation

* feat!: make protocol unions extensible

* ci: add discovery conformance coverage
This commit is contained in:
Dale Seo 2026-07-16 13:47:30 -04:00 • committed by GitHub
commit 3549e86d28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1325 additions and 98 deletions

View file

@ -562,6 +562,22 @@
"CustomResult": {
"description": "A catch-all response either side can use for custom requests."
},
"DiscoverRequestMethod": {
"type": "string",
"format": "const",
"const": "server/discover"
},
"DiscoverRequestParams": {
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/RequestMetaObject"
}
},
"required": [
"_meta"
]
},
"ElicitResult": {
"description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.",
"type": "object",
@ -1120,6 +1136,9 @@
{
"$ref": "#/definitions/Request4"
},
{
"$ref": "#/definitions/Request5"
},
{
"$ref": "#/definitions/RequestOptionalParam"
},
@ -1129,9 +1148,6 @@
{
"$ref": "#/definitions/RequestOptionalParam3"
},
{
"$ref": "#/definitions/Request5"
},
{
"$ref": "#/definitions/Request6"
},
@ -1141,21 +1157,24 @@
{
"$ref": "#/definitions/Request8"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1548,6 +1567,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskMethod"
},
"params": {
"$ref": "#/definitions/GetTaskParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1563,7 +1598,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1580,6 +1615,22 @@
]
},
"Request2": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/DiscoverRequestMethod"
},
"params": {
"$ref": "#/definitions/DiscoverRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request3": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1595,7 +1646,7 @@
"params"
]
},
"Request3": {
"Request4": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1611,7 +1662,7 @@
"params"
]
},
"Request4": {
"Request5": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1627,7 +1678,7 @@
"params"
]
},
"Request5": {
"Request6": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1643,7 +1694,7 @@
"params"
]
},
"Request6": {
"Request7": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1659,7 +1710,7 @@
"params"
]
},
"Request7": {
"Request8": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1675,7 +1726,7 @@
"params"
]
},
"Request8": {
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1691,22 +1742,6 @@
"params"
]
},
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskMethod"
},
"params": {
"$ref": "#/definitions/GetTaskParams"
}
},
"required": [
"method",
"params"
]
},
"RequestMetaObject": {
"description": "Metadata reserved by MCP on requests. Extension keys are also allowed.",
"type": "object",

View file

@ -562,6 +562,22 @@
"CustomResult": {
"description": "A catch-all response either side can use for custom requests."
},
"DiscoverRequestMethod": {
"type": "string",
"format": "const",
"const": "server/discover"
},
"DiscoverRequestParams": {
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/RequestMetaObject"
}
},
"required": [
"_meta"
]
},
"ElicitResult": {
"description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.",
"type": "object",
@ -1120,6 +1136,9 @@
{
"$ref": "#/definitions/Request4"
},
{
"$ref": "#/definitions/Request5"
},
{
"$ref": "#/definitions/RequestOptionalParam"
},
@ -1129,9 +1148,6 @@
{
"$ref": "#/definitions/RequestOptionalParam3"
},
{
"$ref": "#/definitions/Request5"
},
{
"$ref": "#/definitions/Request6"
},
@ -1141,21 +1157,24 @@
{
"$ref": "#/definitions/Request8"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1548,6 +1567,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskMethod"
},
"params": {
"$ref": "#/definitions/GetTaskParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1563,7 +1598,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1580,6 +1615,22 @@
]
},
"Request2": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/DiscoverRequestMethod"
},
"params": {
"$ref": "#/definitions/DiscoverRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request3": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1595,7 +1646,7 @@
"params"
]
},
"Request3": {
"Request4": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1611,7 +1662,7 @@
"params"
]
},
"Request4": {
"Request5": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1627,7 +1678,7 @@
"params"
]
},
"Request5": {
"Request6": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1643,7 +1694,7 @@
"params"
]
},
"Request6": {
"Request7": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1659,7 +1710,7 @@
"params"
]
},
"Request7": {
"Request8": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1675,7 +1726,7 @@
"params"
]
},
"Request8": {
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1691,22 +1742,6 @@
"params"
]
},
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskMethod"
},
"params": {
"$ref": "#/definitions/GetTaskParams"
}
},
"required": [
"method",
"params"
]
},
"RequestMetaObject": {
"description": "Metadata reserved by MCP on requests. Extension keys are also allowed.",
"type": "object",

View file

@ -743,6 +743,83 @@
"CustomResult": {
"description": "A catch-all response either side can use for custom requests."
},
"DiscoverResult": {
"description": "The server's response to a [`DiscoverRequest`].",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level response metadata.",
"anyOf": [
{
"$ref": "#/definitions/MetaObject"
},
{
"type": "null"
}
]
},
"cacheScope": {
"description": "Whether the cached result may be shared across authorization contexts.",
"allOf": [
{
"$ref": "#/definitions/CacheScope"
}
]
},
"capabilities": {
"description": "Capabilities provided by this server.",
"allOf": [
{
"$ref": "#/definitions/ServerCapabilities"
}
]
},
"instructions": {
"description": "Optional guidance for using the server.",
"type": [
"string",
"null"
]
},
"resultType": {
"description": "Identifies how the result should be parsed.",
"allOf": [
{
"$ref": "#/definitions/ResultType"
}
]
},
"serverInfo": {
"description": "Information about the server implementation.",
"allOf": [
{
"$ref": "#/definitions/Implementation"
}
]
},
"supportedVersions": {
"description": "Protocol versions implemented by this server.",
"type": "array",
"items": {
"$ref": "#/definitions/ProtocolVersion"
}
},
"ttlMs": {
"description": "How long clients may consider this response fresh, in milliseconds.",
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"resultType",
"supportedVersions",
"capabilities",
"serverInfo",
"ttlMs",
"cacheScope"
]
},
"ElicitRequestParams": {
"description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```",
"anyOf": [
@ -3194,6 +3271,9 @@
},
"ServerResult": {
"anyOf": [
{
"$ref": "#/definitions/DiscoverResult"
},
{
"$ref": "#/definitions/InitializeResult"
},

View file

@ -743,6 +743,83 @@
"CustomResult": {
"description": "A catch-all response either side can use for custom requests."
},
"DiscoverResult": {
"description": "The server's response to a [`DiscoverRequest`].",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level response metadata.",
"anyOf": [
{
"$ref": "#/definitions/MetaObject"
},
{
"type": "null"
}
]
},
"cacheScope": {
"description": "Whether the cached result may be shared across authorization contexts.",
"allOf": [
{
"$ref": "#/definitions/CacheScope"
}
]
},
"capabilities": {
"description": "Capabilities provided by this server.",
"allOf": [
{
"$ref": "#/definitions/ServerCapabilities"
}
]
},
"instructions": {
"description": "Optional guidance for using the server.",
"type": [
"string",
"null"
]
},
"resultType": {
"description": "Identifies how the result should be parsed.",
"allOf": [
{
"$ref": "#/definitions/ResultType"
}
]
},
"serverInfo": {
"description": "Information about the server implementation.",
"allOf": [
{
"$ref": "#/definitions/Implementation"
}
]
},
"supportedVersions": {
"description": "Protocol versions implemented by this server.",
"type": "array",
"items": {
"$ref": "#/definitions/ProtocolVersion"
}
},
"ttlMs": {
"description": "How long clients may consider this response fresh, in milliseconds.",
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"resultType",
"supportedVersions",
"capabilities",
"serverInfo",
"ttlMs",
"cacheScope"
]
},
"ElicitRequestParams": {
"description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```",
"anyOf": [
@ -3194,6 +3271,9 @@
},
"ServerResult": {
"anyOf": [
{
"$ref": "#/definitions/DiscoverResult"
},
{
"$ref": "#/definitions/InitializeResult"
},

View file

@ -0,0 +1,108 @@
use rmcp::model::{
ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData,
JsonRpcRequest, JsonRpcResponse, ProtocolVersion, ServerJsonRpcMessage, ServerResult,
};
use serde_json::json;
#[test]
fn discover_request_deserializes_with_request_meta() {
let message: ClientJsonRpcMessage = serde_json::from_value(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "test-client",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}))
.expect("discover request should deserialize");
let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = message else {
panic!("expected request");
};
let ClientRequest::DiscoverRequest(request) = request else {
panic!("expected discover request");
};
assert_eq!(
request
.extensions
.get::<rmcp::model::RequestMetaObject>()
.and_then(|meta| meta.protocol_version()),
Some(ProtocolVersion::V_2026_07_28)
);
}
#[test]
fn discover_result_deserializes_to_typed_variant() {
let message: ServerJsonRpcMessage = serde_json::from_value(json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2025-11-25", "2026-07-28"],
"capabilities": { "tools": {} },
"serverInfo": {
"name": "test-server",
"version": "1.0.0"
},
"ttlMs": 0,
"cacheScope": "private"
}
}))
.expect("discover result should deserialize");
let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else {
panic!("expected response");
};
let ServerResult::DiscoverResult(DiscoverResult {
supported_versions, ..
}) = result
else {
panic!("expected discover result");
};
assert_eq!(
supported_versions,
vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28]
);
}
#[test]
fn unsupported_protocol_version_error_matches_draft_schema() {
let error = ErrorData::unsupported_protocol_version(
ProtocolVersion::V_2026_07_28,
&[ProtocolVersion::V_2025_11_25],
);
assert_eq!(error.code, ErrorCode::UNSUPPORTED_PROTOCOL_VERSION);
assert_eq!(
error.data,
Some(json!({
"requested": "2026-07-28",
"supported": ["2025-11-25"]
}))
);
}
#[test]
fn missing_required_capability_error_matches_draft_schema() {
let required = ClientCapabilities::builder().enable_elicitation().build();
let error = ErrorData::missing_required_client_capability(required);
assert_eq!(error.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY);
assert_eq!(
error.data,
Some(json!({
"requiredCapabilities": {
"elicitation": {}
}
}))
);
}

View file

@ -0,0 +1,77 @@
#![cfg(all(feature = "client", not(feature = "local")))]
use rmcp::{
ClientHandler, ServerHandler, ServiceExt,
model::{
ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities,
ServerInfo,
},
select_protocol_version,
};
#[derive(Clone, Default)]
struct DiscoveryServer;
impl ServerHandler for DiscoveryServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("discovery-server", "1.0.0"))
}
}
#[derive(Clone, Default)]
struct DiscoveryClient;
impl ClientHandler for DiscoveryClient {}
#[test]
fn select_protocol_version_uses_client_preference_order() {
let selected = select_protocol_version(
&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25],
&[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28],
);
assert_eq!(selected, Some(ProtocolVersion::V_2026_07_28));
}
#[test]
fn select_protocol_version_returns_none_without_overlap() {
let selected = select_protocol_version(
&[ProtocolVersion::V_2026_07_28],
&[ProtocolVersion::V_2025_11_25],
);
assert_eq!(selected, None);
}
#[tokio::test]
async fn client_discover_helper_returns_typed_result() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
tokio::spawn(async move {
let _ = DiscoveryServer
.serve(server_transport)
.await
.expect("server should start")
.waiting()
.await;
});
let client = DiscoveryClient
.serve(client_transport)
.await
.expect("client should connect");
let mut meta = RequestMetaObject::new();
meta.set_protocol_version(ProtocolVersion::V_2026_07_28);
meta.set_client_info(Implementation::new("discovery-client", "1.0.0"));
meta.set_client_capabilities(ClientCapabilities::default());
let result = client
.discover(meta)
.await
.expect("discover should succeed");
assert_eq!(
result.server_info,
Implementation::new("discovery-server", "1.0.0")
);
client.cancel().await.expect("client should cancel");
}

View file

@ -0,0 +1,346 @@
#![cfg(all(
not(feature = "local"),
feature = "reqwest",
feature = "transport-streamable-http-server"
))]
use std::borrow::Cow;
use rmcp::{
ServerHandler,
model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo},
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) -> ServerInfo {
ServerInfo::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_stateful_mode(json_response, false).await
}
async fn spawn_server_with_stateful_mode(
json_response: bool,
stateful_mode: bool,
) -> (reqwest::Client, String, CancellationToken) {
let cancellation_token = CancellationToken::new();
let config = StreamableHttpServerConfig::default()
.with_stateful_mode(stateful_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": {} },
"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_stateful_mode() {
let (client, url, cancellation_token) = spawn_server_with_stateful_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_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();
}