diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6531e6ee..a2e61430 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1113,7 +1113,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] @@ -1124,7 +1124,8 @@ pub struct DiscoverResult { pub supported_versions: Vec, /// Capabilities provided by this server. pub capabilities: ServerCapabilities, - /// Information about the server implementation. + /// Information about the server implementation. Also accepted from + /// `_meta["io.modelcontextprotocol/serverInfo"]` during deserialization. pub server_info: Implementation, /// Optional guidance for using the server. #[serde(skip_serializing_if = "Option::is_none")] @@ -1138,6 +1139,53 @@ 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 helper = Helper::deserialize(deserializer)?; + let server_info = match helper.server_info { + Some(server_info) => server_info, + None => { + let metadata_server_info = helper + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("io.modelcontextprotocol/serverInfo")) + .ok_or_else(|| serde::de::Error::missing_field("serverInfo"))?; + + serde_json::from_value(metadata_server_info.clone()) + .map_err(serde::de::Error::custom)? + } + }; + + Ok(Self { + result_type: helper.result_type, + supported_versions: helper.supported_versions, + capabilities: helper.capabilities, + server_info, + 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( diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs index 3f689b98..47c262fb 100644 --- a/crates/rmcp/tests/test_server_discover.rs +++ b/crates/rmcp/tests/test_server_discover.rs @@ -74,6 +74,110 @@ fn discover_result_deserializes_to_typed_variant() { ); } +#[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.name, "conformance-mock-server"); + assert_eq!(result.server_info.version, "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_top_level_server_info_over_namespaced_metadata() { + 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.name, "top-level-server"); + assert_eq!(result.server_info.version, "2.0.0"); + assert_eq!( + result + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("unrelated")), + Some(&json!(true)) + ); +} + +#[test] +fn discover_result_requires_valid_top_level_or_namespaced_server_info() { + let result = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "unrelated": true } + }); + + assert!(serde_json::from_value::(result).is_err()); + + let malformed_server_info = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } + }); + + assert!(serde_json::from_value::(malformed_server_info).is_err()); +} + #[test] fn unsupported_protocol_version_error_matches_draft_schema() { let error = ErrorData::unsupported_protocol_version(