diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dc5de752a..ced1875d8 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - CONFORMANCE_VERSION: "0.2.0-alpha.9" + CONFORMANCE_VERSION: "0.2.0-alpha.10" jobs: server: diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index 8400079b0..0ebbeeff1 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -21,3 +21,6 @@ server: [] client: # Informational OAuth extension scenarios. - auth/enterprise-managed-authorization + - auth/dpop + - auth/dpop-nonce + - auth/wif-jwt-bearer diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 08890fab2..14aa31d2a 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1085,6 +1085,66 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +/// Information negotiated about a server peer. +/// +/// Unlike [`InitializeResult`], the server implementation identity is optional +/// because discovery responses are not required to provide it. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ServerPeerInfo { + /// The negotiated MCP protocol version. + pub protocol_version: ProtocolVersion, + /// The capabilities this server provides. + pub capabilities: ServerCapabilities, + /// Information about the server implementation, when provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, + /// Optional human-readable instructions about using this 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 ServerPeerInfo { + /// Create peer information without a server implementation identity. + pub fn new(protocol_version: ProtocolVersion, capabilities: ServerCapabilities) -> Self { + Self { + protocol_version, + capabilities, + server_info: None, + instructions: None, + meta: None, + } + } + + /// Set the server implementation identity. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = Some(server_info); + self + } + + /// Set instructions supplied by the server. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } +} + +impl From for ServerPeerInfo { + fn from(result: InitializeResult) -> Self { + Self { + protocol_version: result.protocol_version, + capabilities: result.capabilities, + server_info: Some(result.server_info), + instructions: result.instructions, + meta: result.meta, + } + } +} + const_string!(DiscoverRequestMethod = "server/discover"); /// Parameters for [`DiscoverRequest`]. @@ -1116,9 +1176,9 @@ impl schemars::JsonSchema for DiscoverRequestParams { pub type DiscoverRequest = Request; /// The server's response to a [`DiscoverRequest`]. -#[derive(Debug, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct DiscoverResult { /// Identifies how the result should be parsed. @@ -1127,8 +1187,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, @@ -1141,65 +1199,15 @@ pub struct DiscoverResult { pub meta: Option, } -impl<'de> Deserialize<'de> for DiscoverResult { - fn deserialize<__D>(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 { + const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; + /// Create a non-cacheable private discovery result. - pub fn new( - supported_versions: Vec, - capabilities: ServerCapabilities, - server_info: Implementation, - ) -> Self { + pub fn new(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, @@ -1207,6 +1215,31 @@ impl DiscoverResult { } } + /// Return the server implementation information stored in result metadata. + pub fn server_info(&self) -> Option { + self.meta + .as_ref()? + .0 + .get(Self::SERVER_INFO_META_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + } + + /// Store server implementation information in result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + let server_info = + serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); + self.meta + .get_or_insert_default() + .0 + .insert(Self::SERVER_INFO_META_KEY.to_owned(), server_info); + } + + /// Store server implementation information in result metadata. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.set_server_info(server_info); + self + } + /// Create a discovery result from the server's initialization information. pub fn from_server_info( supported_versions: Vec, @@ -1219,9 +1252,16 @@ impl DiscoverResult { meta, .. } = server_info; - let mut result = Self::new(supported_versions, capabilities, server_info); - result.instructions = instructions; - result.meta = meta; + let mut result = Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + instructions, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta, + }; + result.set_server_info(server_info); result } @@ -1238,6 +1278,20 @@ impl DiscoverResult { } } +impl ServerPeerInfo { + /// Create peer information from a discovery result and the selected version. + pub fn from_discover_result(protocol_version: ProtocolVersion, result: DiscoverResult) -> Self { + let server_info = result.server_info(); + Self { + protocol_version, + capabilities: result.capabilities, + server_info, + instructions: result.instructions, + meta: result.meta, + } + } +} + #[allow(clippy::derivable_impls)] impl Default for ServerInfo { fn default() -> Self { diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index e779593fe..0a7121e4a 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -374,9 +374,10 @@ impl schemars::JsonSchema for MetaObject { /// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) /// - `io.modelcontextprotocol/logLevel` (SEP-2575) /// -/// The 2026-07-28 schema defines required per-request metadata; earlier -/// protocol versions do not know these keys. All keys therefore stay optional -/// at runtime and in the generated (version-shared) JSON schema — use +/// The 2026-07-28 draft schema requires the protocol-version and +/// client-capabilities keys; client-info is optional. Earlier protocol versions +/// do not know them. All keys therefore stay optional at runtime and in the +/// generated (version-shared) JSON schema — use /// [`RequestMetaObject::missing_required_keys`] to validate a request against /// the negotiated protocol version. /// @@ -395,10 +396,9 @@ impl RequestMetaObject { const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; - /// Request `_meta` keys validated for the 2026-07-28 protocol. - pub const DRAFT_REQUIRED_KEYS: [&str; 3] = [ + /// Request `_meta` keys the 2026-07-28 draft schema marks as required. + pub const DRAFT_REQUIRED_KEYS: [&str; 2] = [ Self::META_KEY_PROTOCOL_VERSION, - Self::META_KEY_CLIENT_INFO, Self::META_KEY_CLIENT_CAPABILITIES, ]; @@ -523,9 +523,6 @@ impl RequestMetaObject { if self.protocol_version().is_none() { missing.push(Self::META_KEY_PROTOCOL_VERSION); } - if self.client_info().is_none() { - missing.push(Self::META_KEY_CLIENT_INFO); - } if self.client_capabilities().is_none() { missing.push(Self::META_KEY_CLIENT_CAPABILITIES); } @@ -838,7 +835,6 @@ mod tests { fn treats_malformed_values_as_missing() { let meta: RequestMetaObject = serde_json::from_value(serde_json::json!({ "io.modelcontextprotocol/protocolVersion": 123, - "io.modelcontextprotocol/clientInfo": "not an implementation", "io.modelcontextprotocol/clientCapabilities": null, })) .unwrap(); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index b23a1496d..9fc1f641a 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, @@ -213,7 +213,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; @@ -776,7 +776,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( @@ -846,13 +846,10 @@ where server_supported: result.supported_versions, }); }; - peer.set_peer_info(ServerInfo { - protocol_version: selected.clone(), - capabilities: result.capabilities, - server_info: result.server_info, - instructions: result.instructions, - meta: result.meta, - }); + peer.set_peer_info(ServerPeerInfo::from_discover_result( + selected.clone(), + result, + )); peer.set_client_request_metadata(ClientRequestMetadata { protocol_version: selected, client_info: client_info.client_info.clone(), @@ -2197,13 +2194,10 @@ mod tests { let peer = disconnected_peer(); let meta = RequestMetaObject::default(); let key = discover_cache_key(); - let expected = DiscoverResult::new( - vec![ProtocolVersion::default()], - Default::default(), - crate::model::Implementation::from_build_env(), - ) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Public); + let expected = DiscoverResult::new(vec![ProtocolVersion::default()], Default::default()) + .with_server_info(crate::model::Implementation::from_build_env()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public); peer.cache_response( key, ServerResult::DiscoverResult(expected.clone()), diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index 6c7984c5d..960e1cf53 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -51,6 +51,14 @@ async fn client_initialization_accepts_stringified_numeric_response_id() { .serve(client_transport) .await .expect("client should accept stringified initialize response ID"); + assert!( + client + .peer_info() + .expect("peer info should be retained") + .server_info + .is_some(), + "initialize always provides a server implementation identity" + ); client.cancel().await.expect("cancel client"); server_task.await.expect("server task"); } diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 375364e88..4b75c3550 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -36,11 +36,13 @@ async fn discover_startup_accepts_stringified_numeric_response_id() { }; server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), RequestId::String(response_id.to_string().into()), )) .await @@ -60,6 +62,110 @@ async fn discover_startup_accepts_stringified_numeric_response_id() { server_task.await.expect("server task"); } +#[tokio::test] +#[allow(deprecated)] +async fn discover_startup_accepts_missing_optional_server_info() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let (rejection_observed_tx, rejection_observed_rx) = tokio::sync::oneshot::channel(); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover_request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let mut result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::builder().enable_tools().build(), + ); + result.instructions = Some("discovery instructions".into()); + result.meta = Some(rmcp::model::MetaObject::new()); + result + .meta + .as_mut() + .expect("metadata") + .0 + .insert("example.test/key".into(), serde_json::json!(7)); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(result), + discover_request.id, + )) + .await + .expect("send discover response"); + + server + .send(ServerJsonRpcMessage::request( + rmcp::model::ServerRequest::CreateMessageRequest( + rmcp::model::CreateMessageRequest::new( + rmcp::model::CreateMessageRequestParams::new( + vec![rmcp::model::SamplingMessage::user_text("unsolicited")], + 16, + ), + ), + ), + RequestId::Number(99), + )) + .await + .expect("send unsolicited server request"); + let Some(ClientJsonRpcMessage::Error(error)) = server.receive().await else { + panic!("expected unsolicited server request to be rejected"); + }; + assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); + rejection_observed_tx + .send(()) + .expect("signal observed rejection"); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected normal request") + else { + panic!("expected normal request"); + }; + assert_eq!( + request.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + request.id, + )) + .await + .expect("send list tools response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("missing optional server info should not fail discovery"); + let peer_info = client.peer_info().expect("peer info should be retained"); + assert_eq!(peer_info.protocol_version, ProtocolVersion::V_2026_07_28); + assert!(peer_info.capabilities.tools.is_some()); + assert_eq!(peer_info.server_info, None); + assert_eq!( + peer_info.instructions.as_deref(), + Some("discovery instructions") + ); + assert_eq!( + peer_info + .meta + .as_ref() + .and_then(|meta| meta.0.get("example.test/key")), + Some(&serde_json::json!(7)) + ); + rejection_observed_rx + .await + .expect("server should observe association rejection"); + client.list_tools(None).await.expect("list tools"); + 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); @@ -103,11 +209,13 @@ async fn discover_startup_omits_initialize() { server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), request.id, )) .await @@ -267,11 +375,13 @@ async fn discover_startup_retries_a_mutually_supported_version() { ); server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), second.id, )) .await @@ -326,11 +436,13 @@ async fn discover_startup_retries_current_version_once_when_server_reports_it_su ); server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), second.id, )) .await diff --git a/crates/rmcp/tests/test_message_schema.rs b/crates/rmcp/tests/test_message_schema.rs index 867b9ef6a..027a2cc86 100644 --- a/crates/rmcp/tests/test_message_schema.rs +++ b/crates/rmcp/tests/test_message_schema.rs @@ -121,6 +121,13 @@ mod tests { let schema = settings .into_generator() .into_root_schema_for::(); + let schema_value = + serde_json::to_value(&schema).expect("Failed to serialize server schema"); + let discover_result = &schema_value["definitions"]["DiscoverResult"]; + assert!( + discover_result["properties"].get("serverInfo").is_none(), + "DiscoverResult serverInfo belongs in namespaced _meta" + ); let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); compare_schemas( 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 fab0954c8..760d9e18c 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 @@ -2255,7 +2255,6 @@ "additionalProperties": true, "required": [ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ] }, 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 fab0954c8..760d9e18c 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 @@ -2255,7 +2255,6 @@ "additionalProperties": true, "required": [ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ] }, 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 a254b9802..fb6876ead 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 @@ -759,14 +759,6 @@ } ] }, - "serverInfo": { - "description": "Information about the server implementation.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, "supportedVersions": { "description": "Protocol versions implemented by this server.", "type": "array", @@ -785,7 +777,6 @@ "resultType", "supportedVersions", "capabilities", - "serverInfo", "ttlMs", "cacheScope" ] 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 a254b9802..fb6876ead 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 @@ -759,14 +759,6 @@ } ] }, - "serverInfo": { - "description": "Information about the server implementation.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, "supportedVersions": { "description": "Protocol versions implemented by this server.", "type": "array", @@ -785,7 +777,6 @@ "resultType", "supportedVersions", "capabilities", - "serverInfo", "ttlMs", "cacheScope" ] 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 0d4a037b2..f2ca3df20 100644 --- a/crates/rmcp/tests/test_server_discover.rs +++ b/crates/rmcp/tests/test_server_discover.rs @@ -103,8 +103,9 @@ fn discover_result_accepts_server_info_in_namespaced_metadata() { 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 server_info = result.server_info().expect("server info should be present"); + assert_eq!(server_info.name, "conformance-mock-server"); + assert_eq!(server_info.version, "1.0.0"); let metadata = result.meta.expect("discovery metadata should be preserved"); assert_eq!( @@ -121,25 +122,42 @@ fn discover_result_accepts_server_info_in_namespaced_metadata() { } #[test] -fn discover_result_serializes_top_level_server_info() { - let result = DiscoverResult::new( +fn discover_result_serializes_server_info_in_namespaced_metadata() { + let mut result = DiscoverResult::new( vec![ProtocolVersion::V_2026_07_28], rmcp::model::ServerCapabilities::default(), - rmcp::model::Implementation::new("test-server", "1.0.0"), ); + result.meta = Some(rmcp::model::MetaObject( + json!({ + "io.modelcontextprotocol/serverInfo": { + "name": "stale-server", + "version": "0.1.0" + }, + "unrelated": { "preserved": true } + }) + .as_object() + .expect("metadata is an object") + .clone(), + )); + result.set_server_info(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["serverInfo"], + serialized["_meta"]["io.modelcontextprotocol/serverInfo"], json!({ "name": "test-server", "version": "1.0.0" }) ); + assert_eq!( + serialized["_meta"]["unrelated"], + json!({ "preserved": true }) + ); } #[test] -fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { +fn discover_result_ignores_legacy_top_level_server_info() { let result: DiscoverResult = serde_json::from_value(json!({ "resultType": "complete", "supportedVersions": ["2026-07-28"], @@ -160,8 +178,11 @@ fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { })) .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"); + let server_info = result + .server_info() + .expect("namespaced server info is present"); + assert_eq!(server_info.name, "metadata-server"); + assert_eq!(server_info.version, "1.0.0"); assert_eq!( result .meta @@ -172,7 +193,7 @@ fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { } #[test] -fn discover_result_requires_valid_top_level_or_namespaced_server_info() { +fn discover_result_allows_missing_or_malformed_optional_server_info() { let result = json!({ "resultType": "complete", "supportedVersions": ["2026-07-28"], @@ -182,7 +203,9 @@ fn discover_result_requires_valid_top_level_or_namespaced_server_info() { "_meta": { "unrelated": true } }); - assert!(serde_json::from_value::(result).is_err()); + let result = + serde_json::from_value::(result).expect("server info metadata is optional"); + assert_eq!(result.server_info(), None); let malformed_server_info = json!({ "resultType": "complete", @@ -193,7 +216,9 @@ fn discover_result_requires_valid_top_level_or_namespaced_server_info() { "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } }); - assert!(serde_json::from_value::(malformed_server_info).is_err()); + let result = serde_json::from_value::(malformed_server_info) + .expect("opaque metadata should not prevent deserialization"); + assert_eq!(result.server_info(), 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/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index 3dbafb1db..7a42f06a0 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -122,9 +122,11 @@ async fn discover_returns_server_metadata_without_session() { "resultType": "complete", "supportedVersions": ["2025-11-25"], "capabilities": { "tools": {} }, - "serverInfo": { - "name": "discovery-server", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "discovery-server", + "version": "1.0.0" + } }, "instructions": "Use the tools carefully", "ttlMs": 0, @@ -327,6 +329,38 @@ async fn discover_rejects_missing_client_capabilities() { cancellation_token.cancel(); } +#[tokio::test] +async fn discover_accepts_missing_optional_client_info() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }); + + 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!(body.get("result").is_some()); + + 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; diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs index 3f33765c5..a268a4b0e 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -262,8 +262,8 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { context: RequestContext, ) -> Result { match request { - ClientRequest::DiscoverRequest(_) => { - Ok(ServerResult::DiscoverResult(DiscoverResult::new( + ClientRequest::DiscoverRequest(_) => Ok(ServerResult::DiscoverResult( + DiscoverResult::new( vec![ProtocolVersion::V_2026_07_28], ServerCapabilities::builder() .enable_tools() @@ -271,9 +271,9 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { .enable_prompts() .enable_prompts_list_changed() .build(), - Implementation::new("malformed-ack-server", "1.0.0"), - ))) - } + ) + .with_server_info(Implementation::new("malformed-ack-server", "1.0.0")), + )), ClientRequest::SubscriptionsListenRequest(_) => { let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( SubscriptionsAcknowledgedNotificationParams::new( diff --git a/crates/rmcp/tests/test_subscriptions_model.rs b/crates/rmcp/tests/test_subscriptions_model.rs index b0c8e51c3..fe9bbdf42 100644 --- a/crates/rmcp/tests/test_subscriptions_model.rs +++ b/crates/rmcp/tests/test_subscriptions_model.rs @@ -215,7 +215,6 @@ fn subscription_schemas_mark_only_draft_required_fields_as_required() { request_schema["properties"]["_meta"]["required"], json!([ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ]) ); 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