Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1120,7 +1120,7 @@ impl schemars::JsonSchema for DiscoverRequestParams {
pub type DiscoverRequest = Request<DiscoverRequestMethod, DiscoverRequestParams>;

/// 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]
Expand All @@ -1145,6 +1145,53 @@ pub struct DiscoverResult {
pub meta: Option<MetaObject>,
}

impl<'de> Deserialize<'de> for DiscoverResult {
fn deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
where
__D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Helper {
result_type: ResultType,
supported_versions: Vec<ProtocolVersion>,
capabilities: ServerCapabilities,
server_info: Option<Implementation>,
instructions: Option<String>,
ttl_ms: u64,
cache_scope: CacheScope,
#[serde(rename = "_meta")]
meta: Option<MetaObject>,
}

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(
Expand Down
122 changes: 122 additions & 0 deletions crates/rmcp/tests/test_server_discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,128 @@ 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_serializes_top_level_server_info() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this right? looking at the latest spec https://modelcontextprotocol.io/specification/draft/schema#discoverresult this is not even a field in the type anymore after a late spec change modelcontextprotocol/modelcontextprotocol#3002.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed this. Thanks for connecting the dots.

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_eq!(
serialized["serverInfo"],
json!({
"name": "test-server",
"version": "1.0.0"
})
);
}

#[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::<DiscoverResult>(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::<DiscoverResult>(malformed_server_info).is_err());
}

#[test]
fn unsupported_protocol_version_error_matches_draft_schema() {
let error = ErrorData::unsupported_protocol_version(
Expand Down