Skip to content

Expose protocol version negotiation to servers that override ServerHandler::initialize #1245

Description

@DaleSeo

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:

  1. 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.

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions