Skip to content
Draft
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
52 changes: 50 additions & 2 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,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 @@ -1124,7 +1124,8 @@ pub struct DiscoverResult {
pub supported_versions: Vec<ProtocolVersion>,
/// 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")]
Expand All @@ -1138,6 +1139,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
104 changes: 104 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,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::<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