Summary
A server that overrides ServerHandler::initialize (for example to record telemetry or to keep a handle on the peer) currently has no way to reuse rmcp's protocol version negotiation. The logic lives in a pub(crate) function and in a default method body generated by a macro, so the override has to either reimplement negotiation or drop it and rely on the transport-layer re-negotiation. Both options have drawbacks described below. A small public helper would remove the duplication.
Context
rmcp 3.2.0 negotiates the protocol version in two places, and both are private to the crate:
-
The default ServerHandler::initialize, generated by macro_rules! server_handler_methods in src/handler/server.rs:
fn initialize(&self, request: InitializeRequestParams, context: RequestContext<RoleServer>) -> ... {
context.peer.set_peer_info(request.clone());
let mut info = self.get_info();
let negotiated = negotiate_protocol_version(
&request.protocol_version,
std::mem::take(&mut info.protocol_version),
&self.supported_protocol_versions(),
);
std::future::ready(negotiated.map(|version| {
info.protocol_version = version;
info
}))
}
Because this body is emitted by a macro, an implementor cannot call it as ServerHandler::initialize(self, ...) from inside their own override.
-
negotiate_protocol_version in src/service/server.rs, which is pub(crate):
pub(crate) fn negotiate_protocol_version(
client_requested: &ProtocolVersion,
server_fallback: ProtocolVersion,
server_supported: &[ProtocolVersion],
) -> Result<ProtocolVersion, ErrorData>
The ServerHandler::supported_protocol_versions hook added in 3.2.0 (thank you, it closed a real gap for us) tells rmcp which versions to negotiate over, but it does not let the handler run the negotiation.
What a server has to do today
Apollo MCP Server overrides initialize to count initializations per client name/version and to register the peer for later notifications/tools/list_changed fan-out. To keep the negotiated version correct we carry our own copy of the rule:
fn negotiate_protocol_version(client_requested: &ProtocolVersion) -> ProtocolVersion {
if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested)
&& *client_requested <= MAX_SUPPORTED_PROTOCOL_VERSION
{
client_requested.clone()
} else {
debug!(client_requested = %client_requested, "falling back to server default");
MAX_SUPPORTED_PROTOCOL_VERSION
}
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
static SUPPORTED: LazyLock<Vec<ProtocolVersion>> = LazyLock::new(|| {
ProtocolVersion::KNOWN_VERSIONS
.iter()
.filter(|v| **v <= MAX_SUPPORTED_PROTOCOL_VERSION)
.cloned()
.collect()
});
Cow::Borrowed(&SUPPORTED)
}
Source: https://github.com/apollographql/apollo-mcp-server/blob/main/crates/apollo-mcp-server/src/server/states/running.rs
This works, but it is the same rule written twice (once as contains && <=, once as filter <=), and it has to be kept in step with rmcp's own semantics by hand. When rmcp's negotiation changed between 2.1 and 3.0.1 (#1079, fixed by #1080), the behavior of our override silently diverged from the SDK's until we noticed through a pinned test.
The alternative, dropping negotiation from the override and relying on rmcp's post-handler re-negotiation, is viable in 3.2.0 but has a side effect: rmcp's negotiate_protocol_version logs every fallback at warn. A client that pins an older supported version and uses stateless HTTP would emit a warning on every initialize, which is why we chose debug in our copy.
Proposal
Any one of these would let us delete our copy. Listed from smallest to largest change.
Option A. Make the negotiation function public.
pub fn negotiate_protocol_version(
client_requested: &ProtocolVersion,
server_fallback: ProtocolVersion,
server_supported: &[ProtocolVersion],
) -> Result<ProtocolVersion, ErrorData>
Re-exported from rmcp::service or rmcp::handler::server. Smallest change, but callers still have to assemble info.protocol_version themselves.
Option B. Add a provided method on ServerHandler that does what the default initialize does, minus side effects.
fn negotiate_initialize(
&self,
request: &InitializeRequestParams,
) -> Result<InitializeResult, ErrorData> {
let mut info = self.get_info();
let negotiated = negotiate_protocol_version(
&request.protocol_version,
std::mem::take(&mut info.protocol_version),
&self.supported_protocol_versions(),
);
negotiated.map(|version| { info.protocol_version = version; info })
}
The default initialize would call set_peer_info and then this method. An override becomes:
async fn initialize(&self, request: InitializeRequestParams, context: RequestContext<RoleServer>) -> Result<InitializeResult, McpError> {
record_telemetry(&request);
self.peers.write().await.push(context.peer.clone());
context.peer.set_peer_info(request.clone());
self.negotiate_initialize(&request)
}
This is the option we would use.
Option C. Add an on_initialize hook so most servers never need to override initialize at all.
fn on_initialize(&self, request: &InitializeRequestParams, context: &RequestContext<RoleServer>) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
Called by the default initialize before negotiation. Covers the telemetry and peer-registration cases without exposing negotiation internals. Larger API surface, but it matches how supported_protocol_versions already lets servers influence the default without replacing it.
Two smaller things in the same area
- Log level of the fallback.
negotiate_protocol_version logs at warn when it falls back to the server default. Falling back is the designed behavior for a pinned client, and on stateless HTTP it happens on every request. debug (or info at most) would be less noisy, or the level could be configurable.
- A helper to build the supported list. Most servers that override
supported_protocol_versions will want "every known version up to X". Something like ProtocolVersion::known_up_to(&ProtocolVersion::V_2025_11_25) -> &'static [ProtocolVersion] would replace the LazyLock filter above with one line.
Environment
Happy to open a PR for Option A or B if maintainers agree on the shape.
Summary
A server that overrides
ServerHandler::initialize(for example to record telemetry or to keep a handle on the peer) currently has no way to reuse rmcp's protocol version negotiation. The logic lives in apub(crate)function and in a default method body generated by a macro, so the override has to either reimplement negotiation or drop it and rely on the transport-layer re-negotiation. Both options have drawbacks described below. A small public helper would remove the duplication.Context
rmcp 3.2.0 negotiates the protocol version in two places, and both are private to the crate:
The default
ServerHandler::initialize, generated bymacro_rules! server_handler_methodsinsrc/handler/server.rs:Because this body is emitted by a macro, an implementor cannot call it as
ServerHandler::initialize(self, ...)from inside their own override.negotiate_protocol_versioninsrc/service/server.rs, which ispub(crate):The
ServerHandler::supported_protocol_versionshook added in 3.2.0 (thank you, it closed a real gap for us) tells rmcp which versions to negotiate over, but it does not let the handler run the negotiation.What a server has to do today
Apollo MCP Server overrides
initializeto count initializations per client name/version and to register the peer for laternotifications/tools/list_changedfan-out. To keep the negotiated version correct we carry our own copy of the rule:Source: https://github.com/apollographql/apollo-mcp-server/blob/main/crates/apollo-mcp-server/src/server/states/running.rs
This works, but it is the same rule written twice (once as
contains && <=, once asfilter <=), and it has to be kept in step with rmcp's own semantics by hand. When rmcp's negotiation changed between 2.1 and 3.0.1 (#1079, fixed by #1080), the behavior of our override silently diverged from the SDK's until we noticed through a pinned test.The alternative, dropping negotiation from the override and relying on rmcp's post-handler re-negotiation, is viable in 3.2.0 but has a side effect: rmcp's
negotiate_protocol_versionlogs every fallback atwarn. A client that pins an older supported version and uses stateless HTTP would emit a warning on everyinitialize, which is why we chosedebugin our copy.Proposal
Any one of these would let us delete our copy. Listed from smallest to largest change.
Option A. Make the negotiation function public.
Re-exported from
rmcp::serviceorrmcp::handler::server. Smallest change, but callers still have to assembleinfo.protocol_versionthemselves.Option B. Add a provided method on
ServerHandlerthat does what the defaultinitializedoes, minus side effects.The default
initializewould callset_peer_infoand then this method. An override becomes:This is the option we would use.
Option C. Add an
on_initializehook so most servers never need to overrideinitializeat all.Called by the default
initializebefore negotiation. Covers the telemetry and peer-registration cases without exposing negotiation internals. Larger API surface, but it matches howsupported_protocol_versionsalready lets servers influence the default without replacing it.Two smaller things in the same area
negotiate_protocol_versionlogs atwarnwhen it falls back to the server default. Falling back is the designed behavior for a pinned client, and on stateless HTTP it happens on every request.debug(orinfoat most) would be less noisy, or the level could be configurable.supported_protocol_versionswill want "every known version up to X". Something likeProtocolVersion::known_up_to(&ProtocolVersion::V_2025_11_25) -> &'static [ProtocolVersion]would replace theLazyLockfilter above with one line.Environment
Happy to open a PR for Option A or B if maintainers agree on the shape.