diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 77990176..c4db90f3 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -1,6 +1,6 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; use crate::{ error::ErrorData as McpError, @@ -30,11 +30,46 @@ impl Service for H { let mrtr_supported = protocol_version .as_ref() .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + let requested_version = context.meta.protocol_version(); + let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_)); + if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() { + let supported_versions = self.supported_protocol_versions(); + if !supported_versions.contains(requested_version) { + return Err(McpError::unsupported_protocol_version( + requested_version.clone(), + &supported_versions, + )); + } + } + if matches!(&request, ClientRequest::DiscoverRequest(_)) { + if requested_version.is_none() { + return Err(McpError::invalid_params( + "server/discover requires protocolVersion in request _meta", + None, + )); + } + if context.meta.client_info().is_none() { + return Err(McpError::invalid_params( + "server/discover requires clientInfo in request _meta", + None, + )); + } + if context.meta.client_capabilities().is_none() { + return Err(McpError::invalid_params( + "server/discover requires clientCapabilities in request _meta", + None, + )); + } + } let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) .await .map(ServerResult::InitializeResult), + ClientRequest::DiscoverRequest(_request) => self + .discover(context) + .await + .map(ServerResult::DiscoverResult), ClientRequest::PingRequest(_request) => { self.ping(context).await.map(ServerResult::empty) } @@ -225,6 +260,20 @@ macro_rules! server_handler_methods { ); std::future::ready(Ok(info)) } + /// Return the protocol versions supported by this server. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } + /// Return this server's discovery information. + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(DiscoverResult::from_server_info( + self.supported_protocol_versions().into_owned(), + self.get_info(), + ))) + } fn complete( &self, request: CompleteRequestParams, @@ -479,6 +528,17 @@ macro_rules! impl_server_handler_for_wrapper { (**self).initialize(request, context) } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + (**self).supported_protocol_versions() + } + + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).discover(context) + } + fn complete( &self, request: CompleteRequestParams, diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 022514c9..78ba2e27 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -19,7 +19,7 @@ pub use handler::server::wrapper::Json; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; #[cfg(feature = "client")] -pub use service::{RoleClient, serve_client}; +pub use service::{RoleClient, select_protocol_version, serve_client}; #[cfg(feature = "server")] pub use service::{RoleServer, serve_server}; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 70de62ea..37bf764a 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -519,6 +519,10 @@ pub struct JsonRpcNotification { pub struct ErrorCode(pub i32); impl ErrorCode { + /// The request used a protocol version the server does not support. + pub const UNSUPPORTED_PROTOCOL_VERSION: Self = Self(-32022); + /// Processing the request requires a client capability that was not declared. + pub const MISSING_REQUIRED_CLIENT_CAPABILITY: Self = Self(-32021); pub const HEADER_MISMATCH: Self = Self(-32020); pub const RESOURCE_NOT_FOUND: Self = Self(-32002); pub const INVALID_REQUEST: Self = Self(-32600); @@ -568,6 +572,30 @@ impl ErrorData { pub fn header_mismatch(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::HEADER_MISMATCH, message, data) } + /// Create an unsupported-protocol-version error. + pub fn unsupported_protocol_version( + requested: ProtocolVersion, + supported: &[ProtocolVersion], + ) -> Self { + Self::new( + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + Some(serde_json::json!({ + "requested": requested, + "supported": supported, + })), + ) + } + /// Create a missing-required-capability error. + pub fn missing_required_client_capability(required: ClientCapabilities) -> Self { + Self::new( + ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, + "Missing required client capability", + Some(serde_json::json!({ + "requiredCapabilities": required, + })), + ) + } pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } @@ -993,6 +1021,126 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +const_string!(DiscoverRequestMethod = "server/discover"); + +/// Parameters for [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct DiscoverRequestParams {} + +#[cfg(feature = "schemars")] +#[derive(schemars::JsonSchema)] +#[expect(dead_code, reason = "schema-only representation of request metadata")] +struct DiscoverRequestMetaSchema { + #[schemars(rename = "io.modelcontextprotocol/protocolVersion")] + protocol_version: ProtocolVersion, + #[schemars(rename = "io.modelcontextprotocol/clientInfo")] + client_info: Implementation, + #[schemars(rename = "io.modelcontextprotocol/clientCapabilities")] + client_capabilities: ClientCapabilities, + #[schemars(rename = "io.modelcontextprotocol/logLevel")] + log_level: Option, +} + +#[cfg(feature = "schemars")] +#[derive(schemars::JsonSchema)] +#[expect(dead_code, reason = "schema-only representation of request parameters")] +struct DiscoverRequestParamsSchema { + #[schemars(rename = "_meta")] + meta: DiscoverRequestMetaSchema, +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DiscoverRequestParams { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("DiscoverRequestParams") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + DiscoverRequestParamsSchema::json_schema(generator) + } +} + +/// A request for the server's supported protocol versions and capabilities. +pub type DiscoverRequest = Request; + +/// The server's response to a [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct DiscoverResult { + /// Identifies how the result should be parsed. + pub result_type: ResultType, + /// Protocol versions implemented by this server. + pub supported_versions: Vec, + /// Capabilities provided by this server. + pub capabilities: ServerCapabilities, + /// Information about the server implementation. + pub server_info: Implementation, + /// Optional guidance for using the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// How long clients may consider this response fresh, in milliseconds. + pub ttl_ms: u64, + /// Whether the cached result may be shared across authorization contexts. + pub cache_scope: CacheScope, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl DiscoverResult { + /// Create a non-cacheable private discovery result. + pub fn new( + supported_versions: Vec, + capabilities: ServerCapabilities, + server_info: Implementation, + ) -> Self { + Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + server_info, + instructions: None, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta: None, + } + } + + /// Create a discovery result from the server's initialization information. + pub fn from_server_info( + supported_versions: Vec, + server_info: ServerInfo, + ) -> Self { + let ServerInfo { + capabilities, + server_info, + instructions, + meta, + .. + } = server_info; + let mut result = Self::new(supported_versions, capabilities, server_info); + result.instructions = instructions; + result.meta = meta; + result + } + + /// Set the cache lifetime hint in milliseconds. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = ttl_ms; + self + } + + /// Set the cache scope. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = cache_scope; + self + } +} + #[allow(clippy::derivable_impls)] impl Default for ServerInfo { fn default() -> Self { @@ -3788,6 +3936,7 @@ ts_union!( export type ClientRequest = | PingRequest | InitializeRequest + | DiscoverRequest | CompleteRequest | SetLevelRequest | GetPromptRequest @@ -3811,6 +3960,7 @@ impl ClientRequest { match &self { ClientRequest::PingRequest(r) => r.method.as_str(), ClientRequest::InitializeRequest(r) => r.method.as_str(), + ClientRequest::DiscoverRequest(r) => r.method.as_str(), ClientRequest::CompleteRequest(r) => r.method.as_str(), ClientRequest::SetLevelRequest(r) => r.method.as_str(), ClientRequest::GetPromptRequest(r) => r.method.as_str(), @@ -3882,6 +4032,7 @@ ts_union!( ts_union!( export type ServerResult = + | DiscoverResult | InitializeResult | CompleteResult | GetPromptResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index bc1b94b4..cc70a7d0 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -169,6 +169,7 @@ variant_extension! { ClientRequest { PingRequest InitializeRequest + DiscoverRequest CompleteRequest SetLevelRequest GetPromptRequest diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 92951261..ac76dc02 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -10,13 +10,14 @@ use crate::{ ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, ErrorData, - GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, - GetPromptResult, InitializeRequest, InitializedNotification, InputRequest, - InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, - ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, - ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, - NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, + DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, + GetPromptRequest, GetPromptRequestParams, GetPromptResponse, GetPromptResult, + InitializeRequest, InitializedNotification, InputRequest, InputRequiredResult, + InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, + ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, + ListResourcesResult, ListToolsRequest, ListToolsResult, Meta, NumberOrString, + PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Reference, RequestId, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, @@ -147,6 +148,19 @@ where #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RoleClient; +/// Select the first client-preferred protocol version supported by the server. +/// +/// Returns `None` when no version is shared. +pub fn select_protocol_version( + client_preference: &[ProtocolVersion], + server_supported: &[ProtocolVersion], +) -> Option { + client_preference + .iter() + .find(|version| server_supported.contains(version)) + .cloned() +} + impl ServiceRole for RoleClient { type Req = ClientRequest; type Resp = ClientResult; @@ -363,6 +377,19 @@ macro_rules! method { } impl Peer { + /// Discover the server's supported protocol versions and capabilities. + pub async fn discover(&self, meta: Meta) -> Result { + let mut request = DiscoverRequest::new(DiscoverRequestParams {}); + request.extensions.insert(meta); + let result = self + .send_request(ClientRequest::DiscoverRequest(request)) + .await?; + match result { + ServerResult::DiscoverResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + /// Send one `tools/call` request and return either a final result or an MRTR /// `InputRequiredResult` without driving any follow-up rounds. pub async fn call_tool_once( diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index d8fe2d42..fff8f47c 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -16,9 +16,10 @@ use super::session::{ use crate::{ RoleServer, model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, - GetExtensions, Implementation, InitializeRequest, InitializeRequestParams, - InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId, + ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, + ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, + InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, + ProtocolVersion, RequestId, ServerJsonRpcMessage, }, serve_server, service::serve_directly, @@ -182,7 +183,10 @@ impl StreamableHttpServerConfig { /// Per the MCP 2025-06-18 spec: /// - If the header is present but contains an unsupported version, return 400 Bad Request. /// - If the header is absent, assume `2025-03-26` for backwards compatibility (no error). -fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), BoxResponse> { +fn validate_protocol_version_header( + headers: &http::HeaderMap, + allow_unknown: bool, +) -> Result<(), BoxResponse> { if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { let version_str = value.to_str().map_err(|_| { Response::builder() @@ -198,7 +202,7 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box let is_known = ProtocolVersion::KNOWN_VERSIONS .iter() .any(|v| v.as_str() == version_str); - if !is_known { + if !allow_unknown && !is_known { return Err(Response::builder() .status(http::StatusCode::BAD_REQUEST) .body( @@ -213,6 +217,20 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box Ok(()) } +fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> bool { + match message { + ClientJsonRpcMessage::Request(request) => { + request.request.get_meta().protocol_version().is_some() + } + ClientJsonRpcMessage::Notification(notification) => notification + .notification + .get_meta() + .protocol_version() + .is_some(), + _ => false, + } +} + fn invalid_request_jsonrpc_response( id: Option, message: impl Into>, @@ -226,6 +244,19 @@ fn invalid_request_jsonrpc_response( .expect("valid response") } +fn invalid_params_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::invalid_params(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + #[expect( clippy::result_large_err, reason = "BoxResponse is intentionally large; matches other handlers in this file" @@ -261,6 +292,81 @@ fn validate_header_matches_init_body( Ok(()) } +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_request_protocol_version_meta( + headers: &HeaderMap, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + if matches!(&request.request, ClientRequest::InitializeRequest(_)) { + return Ok(()); + } + let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); + let Some(meta_version) = request.request.get_meta().protocol_version() else { + if is_discover { + return Err(invalid_params_jsonrpc_response( + Some(request.id.clone()), + "Invalid params: server/discover requires protocolVersion in request _meta", + )); + } + return Ok(()); + }; + let Some(header_version) = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + else { + return Err(invalid_request_jsonrpc_response( + Some(request.id.clone()), + "Invalid Request: request _meta protocolVersion requires MCP-Protocol-Version header", + )); + }; + if header_version != meta_version.as_str() { + return Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + format!( + "MCP-Protocol-Version header ({header_version}) does not match request _meta protocolVersion ({meta_version})" + ), + )); + } + Ok(()) +} + +fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { + let ServerJsonRpcMessage::Error(error) = message else { + return http::StatusCode::OK; + }; + match error.error.code { + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + | ErrorCode::INVALID_PARAMS => http::StatusCode::BAD_REQUEST, + ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, + _ => http::StatusCode::OK, + } +} + +fn jsonrpc_message_response( + message: ServerJsonRpcMessage, + map_protocol_status: bool, +) -> Result { + let status = if map_protocol_status { + jsonrpc_http_status(&message) + } else { + http::StatusCode::OK + }; + let body = + serde_json::to_vec(&message).map_err(internal_error_response("serialize json response"))?; + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) +} + fn header_mismatch_jsonrpc_response( id: Option, message: impl Into>, @@ -722,6 +828,51 @@ where (self.service_factory)() } + // The HTTP status must be known before opening an SSE stream. + async fn serve_negotiated_request_directly( + &self, + service: S, + mut request: crate::model::JsonRpcRequest, + parts: http::request::Parts, + ) -> Result { + let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); + request.request.extensions_mut().insert(parts); + let (transport, mut receiver) = + OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); + let service = serve_directly(service, transport, peer_info); + tokio::spawn(async move { + let _ = service.waiting().await; + }); + + let cancel = self.config.cancellation_token.child_token(); + let first = tokio::select! { + message = receiver.recv() => message, + _ = cancel.cancelled() => None, + } + .ok_or_else(|| { + internal_error_response("empty response")(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no response message received from handler", + )) + })?; + + if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK { + return jsonrpc_message_response(first, true); + } + + let stream = futures::stream::once(async move { first }) + .chain(ReceiverStream::new(receiver)) + .map(|message| { + tracing::trace!(?message); + ServerSseMessage::from_message(message) + }); + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) + } + /// Returns the cached input schema for `name`, constructing a service once /// per name to read its `ServerHandler::get_tool` definition. Used to /// validate SEP-2243 `Mcp-Param-*` headers against the request body. @@ -1044,7 +1195,7 @@ where } } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(&parts.headers)?; + validate_protocol_version_header(&parts.headers, false)?; // check if last event id is provided let last_event_id = parts .headers @@ -1174,7 +1325,9 @@ where } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(&part.headers)?; + let has_per_request_version = message_has_per_request_protocol_version(&message); + validate_protocol_version_header(&part.headers, has_per_request_version)?; + validate_request_protocol_version_meta(&part.headers, &message)?; // Validate SEP-2243 standard headers against the body validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; @@ -1219,6 +1372,29 @@ where } } } else { + if matches!( + &message, + ClientJsonRpcMessage::Request(request) + if matches!(&request.request, ClientRequest::DiscoverRequest(_)) + ) { + validate_protocol_version_header( + &part.headers, + message_has_per_request_protocol_version(&message), + )?; + validate_standard_headers(&part.headers, &message, |name| { + self.tool_schema(name) + })?; + validate_request_protocol_version_meta(&part.headers, &message)?; + let ClientJsonRpcMessage::Request(request) = message else { + unreachable!("guarded as a request above"); + }; + let service = self + .get_service() + .map_err(internal_error_response("get service"))?; + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } // Capture init params for external store persistence before // extensions are injected (which would require Clone). let stored_init_params = match &mut message { @@ -1313,6 +1489,7 @@ where // Stateless mode: // - on initialize: the header (if present) must match `params.protocolVersion` // - on every other request: the header must name a known version. + let has_per_request_version = message_has_per_request_protocol_version(&message); match &message { ClientJsonRpcMessage::Request(req) => { if let ClientRequest::InitializeRequest(init_req) = &req.request { @@ -1322,20 +1499,28 @@ where Some(req.id.clone()), )?; } else { - validate_protocol_version_header(&part.headers)?; + validate_protocol_version_header(&part.headers, has_per_request_version)?; } } _ => { - validate_protocol_version_header(&part.headers)?; + validate_protocol_version_header(&part.headers, has_per_request_version)?; } } // Validate SEP-2243 standard headers against the body validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; + validate_request_protocol_version_meta(&part.headers, &message)?; let service = self .get_service() .map_err(internal_error_response("get service"))?; match message { ClientJsonRpcMessage::Request(mut request) => { + let negotiates_per_request = has_per_request_version + || matches!(&request.request, ClientRequest::DiscoverRequest(_)); + if negotiates_per_request { + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } // Build a peer_info so context.protocol_version() works inside handlers. // serve_directly skips the handshake and receives None by default, making // protocol_version() always return None in stateless mode. We reconstruct it: @@ -1362,14 +1547,7 @@ where } { Some(message) => { tracing::trace!(?message); - let body = serde_json::to_vec(&message).map_err(|e| { - internal_error_response("serialize json response")(e) - })?; - Ok(Response::builder() - .status(http::StatusCode::OK) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response")) + jsonrpc_message_response(message, false) } None => Err(internal_error_response("empty response")( std::io::Error::new( @@ -1420,7 +1598,7 @@ where .expect("valid response")); }; // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(request.headers())?; + validate_protocol_version_header(request.headers(), false)?; // close session self.session_manager .close_session(&session_id) diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 81a5ab25..4fa21fe1 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -544,6 +544,51 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverRequestMetaSchema": { + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "anyOf": [ + { + "$ref": "#/definitions/LoggingLevel" + }, + { + "type": "null" + } + ] + }, + "io.modelcontextprotocol/protocolVersion": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/DiscoverRequestMetaSchema" + } + }, + "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", @@ -1081,6 +1126,9 @@ { "$ref": "#/definitions/Request4" }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/RequestOptionalParam" }, @@ -1090,9 +1138,6 @@ { "$ref": "#/definitions/RequestOptionalParam3" }, - { - "$ref": "#/definitions/Request5" - }, { "$ref": "#/definitions/Request6" }, @@ -1102,20 +1147,23 @@ { "$ref": "#/definitions/Request8" }, + { + "$ref": "#/definitions/Request9" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request9" + "$ref": "#/definitions/Request10" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { "$ref": "#/definitions/CustomRequest" @@ -1482,6 +1530,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": { @@ -1497,7 +1561,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": { @@ -1518,10 +1582,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CompleteRequestMethod" + "$ref": "#/definitions/DiscoverRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParams" + "$ref": "#/definitions/DiscoverRequestParams" } }, "required": [ @@ -1534,10 +1598,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SetLevelRequestMethod" + "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParams" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1550,10 +1614,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetPromptRequestMethod" + "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParams" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1566,10 +1630,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" + "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParams" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1582,10 +1646,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1598,10 +1662,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1614,10 +1678,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1630,10 +1694,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskMethod" + "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/GetTaskParams" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 81a5ab25..4fa21fe1 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -544,6 +544,51 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverRequestMetaSchema": { + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "anyOf": [ + { + "$ref": "#/definitions/LoggingLevel" + }, + { + "type": "null" + } + ] + }, + "io.modelcontextprotocol/protocolVersion": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/DiscoverRequestMetaSchema" + } + }, + "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", @@ -1081,6 +1126,9 @@ { "$ref": "#/definitions/Request4" }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/RequestOptionalParam" }, @@ -1090,9 +1138,6 @@ { "$ref": "#/definitions/RequestOptionalParam3" }, - { - "$ref": "#/definitions/Request5" - }, { "$ref": "#/definitions/Request6" }, @@ -1102,20 +1147,23 @@ { "$ref": "#/definitions/Request8" }, + { + "$ref": "#/definitions/Request9" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request9" + "$ref": "#/definitions/Request10" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { "$ref": "#/definitions/CustomRequest" @@ -1482,6 +1530,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": { @@ -1497,7 +1561,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": { @@ -1518,10 +1582,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CompleteRequestMethod" + "$ref": "#/definitions/DiscoverRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParams" + "$ref": "#/definitions/DiscoverRequestParams" } }, "required": [ @@ -1534,10 +1598,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SetLevelRequestMethod" + "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParams" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1550,10 +1614,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetPromptRequestMethod" + "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParams" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1566,10 +1630,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" + "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParams" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1582,10 +1646,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1598,10 +1662,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1614,10 +1678,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1630,10 +1694,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskMethod" + "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/GetTaskParams" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index ddd0eaad..70322ceb 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -651,6 +651,80 @@ "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.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "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": [ @@ -2915,6 +2989,9 @@ }, "ServerResult": { "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, { "$ref": "#/definitions/InitializeResult" }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index ddd0eaad..70322ceb 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -651,6 +651,80 @@ "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.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "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": [ @@ -2915,6 +2989,9 @@ }, "ServerResult": { "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, { "$ref": "#/definitions/InitializeResult" }, diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs new file mode 100644 index 00000000..eaeba9ef --- /dev/null +++ b/crates/rmcp/tests/test_server_discover.rs @@ -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::() + .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": {} + } + })) + ); +} diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs new file mode 100644 index 00000000..cfb896a0 --- /dev/null +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -0,0 +1,76 @@ +#![cfg(all(feature = "client", not(feature = "local")))] + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientCapabilities, Implementation, Meta, ProtocolVersion, 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 = Meta::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"); +} diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs new file mode 100644 index 00000000..ad9a16d0 --- /dev/null +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -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 = + 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(); +}