From a08dd9a4fd0e9ea17628ac2aa1833251e4422cf2 Mon Sep 17 00:00:00 2001 From: Thomas Sarlandie Date: Fri, 24 Jul 2026 01:21:41 +0000 Subject: [PATCH] fix: accept namespaced discovery server information (#1039) --- crates/rmcp/src/model.rs | 123 +++++++++++++- crates/rmcp/src/model/meta.rs | 15 ++ crates/rmcp/src/service/client.rs | 11 +- .../rmcp/tests/test_client_lifecycle_modes.rs | 45 +++++ crates/rmcp/tests/test_mrtr_behavior.rs | 4 +- crates/rmcp/tests/test_server_discover.rs | 159 +++++++++++++++++- .../rmcp/tests/test_server_discover_client.rs | 4 +- examples/clients/src/progress_client.rs | 10 +- 8 files changed, 350 insertions(+), 21 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 307ce525f..f0cc613d1 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1089,6 +1089,42 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +/// Information learned about a server by a client. +/// +/// Legacy initialization requires [`server_info`](Self::server_info), while +/// the modern discovery lifecycle carries it as optional, self-reported result +/// metadata. The remaining fields are available in both lifecycle modes. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ServerPeerInfo { + /// The negotiated protocol version. + pub protocol_version: ProtocolVersion, + /// The capabilities advertised by the server. + pub capabilities: ServerCapabilities, + /// Optional, self-reported server implementation identity. + pub server_info: Option, + /// Optional human-readable instructions about using the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl From for ServerPeerInfo { + fn from(info: ServerInfo) -> Self { + Self { + protocol_version: info.protocol_version, + capabilities: info.capabilities, + server_info: Some(info.server_info), + instructions: info.instructions, + meta: info.meta, + } + } +} + const_string!(DiscoverRequestMethod = "server/discover"); /// Parameters for [`DiscoverRequest`]. @@ -1120,7 +1156,7 @@ impl schemars::JsonSchema for DiscoverRequestParams { pub type DiscoverRequest = Request; /// The server's response to a [`DiscoverRequest`]. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -1131,8 +1167,6 @@ pub struct DiscoverResult { 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, @@ -1145,18 +1179,77 @@ pub struct DiscoverResult { pub meta: Option, } +impl<'de> Deserialize<'de> for DiscoverResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: ResultType, + supported_versions: Vec, + capabilities: ServerCapabilities, + server_info: Option, + instructions: Option, + ttl_ms: u64, + cache_scope: CacheScope, + #[serde(rename = "_meta")] + meta: Option, + } + + let mut helper = Helper::deserialize(deserializer)?; + let has_canonical_server_info = helper + .meta + .as_ref() + .is_some_and(|metadata| metadata.0.contains_key(MetaObject::META_KEY_SERVER_INFO)); + if !has_canonical_server_info + && let Some(server_info) = helper + .server_info + .and_then(|value| serde_json::from_value::(value).ok()) + { + helper + .meta + .get_or_insert_with(MetaObject::new) + .set_server_info(server_info); + } + + Ok(Self { + result_type: helper.result_type, + supported_versions: helper.supported_versions, + capabilities: helper.capabilities, + instructions: helper.instructions, + ttl_ms: helper.ttl_ms, + cache_scope: helper.cache_scope, + meta: helper.meta, + }) + } +} + impl DiscoverResult { /// Create a non-cacheable private discovery result. pub fn new( supported_versions: Vec, capabilities: ServerCapabilities, server_info: Implementation, + ) -> Self { + Self::new_without_server_info(supported_versions, capabilities) + .with_server_info(server_info) + } + + /// Create a non-cacheable private discovery result without a server identity. + /// + /// Server identity is optional display-only metadata. Servers should normally + /// use [`DiscoverResult::new`], but this constructor supports peers that do + /// not advertise an implementation name and version. + pub fn new_without_server_info( + supported_versions: Vec, + capabilities: ServerCapabilities, ) -> Self { Self { result_type: ResultType::COMPLETE, supported_versions, capabilities, - server_info, instructions: None, ttl_ms: 0, cache_scope: CacheScope::Private, @@ -1178,10 +1271,30 @@ impl DiscoverResult { } = server_info; let mut result = Self::new(supported_versions, capabilities, server_info); result.instructions = instructions; - result.meta = meta; + if let Some(meta) = meta { + result.meta.get_or_insert_with(MetaObject::new).extend(meta); + } result } + /// Return the optional self-reported server identity from result metadata. + pub fn server_info(&self) -> Option { + self.meta.as_ref().and_then(MetaObject::server_info) + } + + /// Set the self-reported server identity in canonical result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + self.meta + .get_or_insert_with(MetaObject::new) + .set_server_info(server_info); + } + + /// Set the self-reported server identity in canonical result metadata. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.set_server_info(server_info); + self + } + /// Set the cache lifetime hint in milliseconds. pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { self.ttl_ms = ttl_ms; diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 0f7d90ce6..8b76a1c54 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -253,6 +253,8 @@ pub struct MetaObject(pub JsonObject); pub use self::MetaObject as Meta; impl MetaObject { + /// Reserved result metadata key for the server implementation identity. + pub const META_KEY_SERVER_INFO: &'static str = "io.modelcontextprotocol/serverInfo"; /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). const TRACEPARENT_FIELD: &str = "traceparent"; /// Reserved `_meta` key for the W3C Trace Context `tracestate` value (SEP-414). @@ -322,6 +324,19 @@ impl MetaObject { self.0.extend(other.0); } + /// Get the self-reported server implementation identity, if present and valid. + /// + /// This value is intended for display, logging, and debugging. Callers must + /// not use it for behavioral or security decisions. + pub fn server_info(&self) -> Option { + self.decode_value(Self::META_KEY_SERVER_INFO) + } + + /// Set the self-reported server implementation identity. + pub fn set_server_info(&mut self, server_info: Implementation) { + self.insert_serialized(Self::META_KEY_SERVER_INFO, server_info); + } + fn decode_value(&self, key: &str) -> Option where T: for<'de> Deserialize<'de>, diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 40d410d5b..355a2af5a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -25,7 +25,7 @@ use crate::{ NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, - ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, + ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, @@ -188,7 +188,7 @@ impl ServiceRole for RoleClient { type PeerResp = ServerResult; type PeerNot = ServerNotification; type Info = ClientInfo; - type PeerInfo = ServerInfo; + type PeerInfo = ServerPeerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; @@ -751,7 +751,7 @@ where let ServerResult::InitializeResult(initialize_result) = response else { return Err(ClientInitializeError::ExpectedInitResult(Some(response))); }; - peer.set_peer_info(initialize_result); + peer.set_peer_info(initialize_result.into()); // send notification let notification = ClientJsonRpcMessage::notification( @@ -821,10 +821,11 @@ where server_supported: result.supported_versions, }); }; - peer.set_peer_info(ServerInfo { + let server_info = result.server_info(); + peer.set_peer_info(ServerPeerInfo { protocol_version: selected.clone(), capabilities: result.capabilities, - server_info: result.server_info, + server_info, instructions: result.instructions, meta: result.meta, }); diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 375364e88..0143b3c48 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -60,6 +60,51 @@ async fn discover_startup_accepts_stringified_numeric_response_id() { server_task.await.expect("server task"); } +#[tokio::test] +async fn discover_startup_accepts_anonymous_server() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let result: DiscoverResult = serde_json::from_value(serde_json::json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private" + })) + .expect("anonymous discovery result"); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(result), + request.id, + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("anonymous server should remain discoverable"); + let peer = client + .peer_info() + .expect("discovery should store peer state"); + assert_eq!(peer.protocol_version, ProtocolVersion::V_2026_07_28); + assert_eq!(peer.server_info, None); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + #[tokio::test] async fn high_level_server_accepts_discover_startup_without_initialize() { let (server_transport, client_transport) = tokio::io::duplex(4096); diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index cbdd6ddf4..33332c2b7 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -316,7 +316,7 @@ where let client = serve_directly::( MrtrClient, client_transport, - Some(client_peer_info), + Some(client_peer_info.into()), ); let result = body(client).await; @@ -580,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re let client = serve_directly::( MrtrClient, client_transport, - Some(server_info(ProtocolVersion::V_2026_07_28)), + Some(server_info(ProtocolVersion::V_2026_07_28).into()), ); let result = client diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs index 3f689b988..5c0d8077c 100644 --- a/crates/rmcp/tests/test_server_discover.rs +++ b/crates/rmcp/tests/test_server_discover.rs @@ -61,17 +61,166 @@ fn discover_result_deserializes_to_typed_variant() { let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { panic!("expected response"); }; - let ServerResult::DiscoverResult(DiscoverResult { - supported_versions, .. - }) = result - else { + let ServerResult::DiscoverResult(result) = result else { panic!("expected discover result"); }; assert_eq!( - supported_versions, + result.supported_versions, vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28] ); + assert_eq!( + result.server_info(), + Some(rmcp::model::Implementation::new("test-server", "1.0.0")) + ); + let serialized = serde_json::to_value(result).expect("serialize discovery result"); + assert!(serialized.get("serverInfo").is_none()); + assert_eq!( + serialized["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "test-server" + ); +} + +#[test] +fn discover_result_accepts_server_info_in_namespaced_metadata() { + let message: ServerJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "conformance-mock-server", + "version": "1.0.0" + }, + "unrelated": { "preserved": true } + } + } + })) + .expect("discovery response with namespaced server info should deserialize"); + + let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { + panic!("expected response"); + }; + let ServerResult::DiscoverResult(result) = result else { + panic!("expected discovery response, not a tool-call result"); + }; + + assert_eq!( + result.server_info(), + Some(rmcp::model::Implementation::new( + "conformance-mock-server", + "1.0.0" + )) + ); + + let metadata = result.meta.expect("discovery metadata should be preserved"); + assert_eq!( + metadata.0.get("io.modelcontextprotocol/serverInfo"), + Some(&json!({ + "name": "conformance-mock-server", + "version": "1.0.0" + })) + ); + assert_eq!( + metadata.0.get("unrelated"), + Some(&json!({ "preserved": true })) + ); +} + +#[test] +fn discover_result_prefers_canonical_metadata_over_legacy_top_level_server_info() { + let result: DiscoverResult = serde_json::from_value(json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "serverInfo": { + "name": "top-level-server", + "version": "2.0.0" + }, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "metadata-server", + "version": "1.0.0" + }, + "unrelated": true + } + })) + .expect("top-level server info should remain supported"); + + assert_eq!( + result.server_info(), + Some(rmcp::model::Implementation::new("metadata-server", "1.0.0")) + ); + assert_eq!( + result + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("unrelated")), + Some(&json!(true)) + ); +} + +#[test] +fn discover_result_accepts_anonymous_or_malformed_server_identity() { + let result = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "unrelated": true } + }); + + let result = + serde_json::from_value::(result).expect("anonymous server is valid"); + assert_eq!(result.server_info(), None); + + let malformed_server_info = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } + }); + + let malformed_server_info = serde_json::from_value::(malformed_server_info) + .expect("malformed display-only identity must not reject discovery"); + assert_eq!(malformed_server_info.server_info(), None); +} + +#[test] +fn discover_result_serializes_server_info_only_in_metadata() { + let result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + rmcp::model::ServerCapabilities::default(), + rmcp::model::Implementation::new("test-server", "1.0.0"), + ); + + let serialized = serde_json::to_value(result).expect("serialize discovery result"); + assert!(serialized.get("serverInfo").is_none()); + assert_eq!( + serialized["_meta"]["io.modelcontextprotocol/serverInfo"], + json!({ + "name": "test-server", + "version": "1.0.0" + }) + ); + + let anonymous = DiscoverResult::new_without_server_info( + vec![ProtocolVersion::V_2026_07_28], + rmcp::model::ServerCapabilities::default(), + ); + let serialized = serde_json::to_value(anonymous).expect("serialize anonymous result"); + assert!(serialized.get("serverInfo").is_none()); + assert!(serialized.get("_meta").is_none()); } #[test] diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs index adbe577ed..3b309ddca 100644 --- a/crates/rmcp/tests/test_server_discover_client.rs +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -70,8 +70,8 @@ async fn client_discover_helper_returns_typed_result() { .expect("discover should succeed"); assert_eq!( - result.server_info, - Implementation::new("discovery-server", "1.0.0") + result.server_info(), + Some(Implementation::new("discovery-server", "1.0.0")) ); client.cancel().await.expect("client should cancel"); } diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index 89c48738d..a9f68c139 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -163,7 +163,10 @@ async fn test_stdio_transport(records: u32) -> Result<()> { // Initialize let server_info = service.peer_info(); if let Some(info) = server_info { - tracing::info!("Connected to server: {:?}", info.server_info.name); + tracing::info!( + "Connected to server: {:?}", + info.server_info.as_ref().map(|server| &server.name) + ); } // List tools @@ -214,7 +217,10 @@ async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { // Initialize let server_info = client.peer_info(); if let Some(info) = server_info { - tracing::info!("Connected to server: {:?}", info.server_info.name); + tracing::info!( + "Connected to server: {:?}", + info.server_info.as_ref().map(|server| &server.name) + ); } // List tools