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
123 changes: 118 additions & 5 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Implementation>,
/// Optional human-readable instructions about using the server.
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// Protocol-level response metadata.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<MetaObject>,
}

impl From<ServerInfo> 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`].
Expand Down Expand Up @@ -1120,7 +1156,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 @@ -1131,8 +1167,6 @@ pub struct DiscoverResult {
pub supported_versions: Vec<ProtocolVersion>,
/// 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<String>,
Expand All @@ -1145,18 +1179,77 @@ 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<serde_json::Value>,
instructions: Option<String>,
ttl_ms: u64,
cache_scope: CacheScope,
#[serde(rename = "_meta")]
meta: Option<MetaObject>,
}

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::<Implementation>(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<ProtocolVersion>,
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<ProtocolVersion>,
capabilities: ServerCapabilities,
) -> Self {
Self {
result_type: ResultType::COMPLETE,
supported_versions,
capabilities,
server_info,
instructions: None,
ttl_ms: 0,
cache_scope: CacheScope::Private,
Expand All @@ -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<Implementation> {
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;
Expand Down
15 changes: 15 additions & 0 deletions crates/rmcp/src/model/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<Implementation> {
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<T>(&self, key: &str) -> Option<T>
where
T: for<'de> Deserialize<'de>,
Expand Down
11 changes: 6 additions & 5 deletions crates/rmcp/src/service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
});
Expand Down
45 changes: 45 additions & 0 deletions crates/rmcp/tests/test_client_lifecycle_modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<rmcp::RoleServer, _, _>::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);
Expand Down
4 changes: 2 additions & 2 deletions crates/rmcp/tests/test_mrtr_behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ where
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(client_peer_info),
Some(client_peer_info.into()),
);

let result = body(client).await;
Expand Down Expand Up @@ -580,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(server_info(ProtocolVersion::V_2026_07_28)),
Some(server_info(ProtocolVersion::V_2026_07_28).into()),
);

let result = client
Expand Down
Loading