Skip to content

Server deadlocks when first request handler sends progress/message notifications #1261

Description

@xuant2005

Bug description

When a modern-protocol (2026-07-28) rmcp server receives a non-initialize request as its very first message on a stdio transport, serve_server_with_ct_inner handles that request inline before serve_inner's peer-drain loop is started.

During that inline handler execution:

  • Server-to-client notifications (notifications/progress, notifications/message) are enqueued into the peer channel.
  • Peer::send_notification() awaits a oneshot responder that is only resolved once the message is actually written to the transport.
  • The peer channel is drained exclusively by serve_inner's spawned task — which does not yet exist.

Result: any handler that emits a notification before returning deadlocks forever. The notification await blocks the handler, the handler blocks the return to serve_inner, and serve_inner never starts to drain the channel.

This affects any client using the 2026-07-28 protocol that skips server/discover and sends e.g. tools/call as its first message. The TypeScript SDK client does exactly this (it probes server/discover on a short-lived sibling process), so the deadlock is hit on the very first real progress test against an rmcp server.

Reproduction

  1. Start an rmcp stdio server with a tool handler that calls context.peer().send_notification(...) (e.g. a progress notification) before returning its result.
  2. Connect a modern-protocol client that sends tools/call with a progressToken as the first JSON-RPC message (no preceding server/discover).
  3. Observe: no notifications arrive, no response arrives, the connection hangs indefinitely.

Sending server/discover (or any other message) first avoids the bug because serve_inner is then running normally by the time the tool handler executes.

Related issues

Suggested fix

Hand the pre-read first request to serve_inner instead of handling it inline:

  • Add an initial_messages: VecDeque<RxJsonRpcMessage<R>> parameter to serve_inner.
  • In serve_server_with_ct_inner, when the first message is not initialize, wrap it as a ClientJsonRpcMessage::request and pass it via VecDeque::from([first_message]) instead of calling service.handle_request(...).await inline.
  • Initialize batch_messages inside the serve loop from initial_messages so the pre-fetched request is dispatched in wire order alongside subsequent transport reads.
  • Other call sites (serve_client_with_ct_inner, the post-initialize path) pass Default::default().

A complete diff against rmcp v3.3.0/v3.3.1 is available in our vendored copy. The patch modifies three files:

  • crates/rmcp/src/service.rsserve_inner accepts and seeds initial_messages
  • crates/rmcp/src/service/server.rs — modern-path first request routed through the loop
  • crates/rmcp/src/service/client.rs — call-site update (passes empty deque)

All existing upstream regression tests pass against the patched copy.

Environment

  • rmcp v3.3.0 / v3.3.1 (latest release as of 2026-09-12, commit e27c5d1)
  • Confirmed present in current main branch
  • Transport: stdio (likely also affects Streamable HTTP when the first POST carries a non-initialize request)

Activity

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

Metadata

Metadata

Assignees

Labels

P1High: significant functionality gap or spec violationT-serviceService layer changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions