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
- Start an rmcp stdio server with a tool handler that calls
context.peer().send_notification(...) (e.g. a progress notification) before returning its result.
- Connect a modern-protocol client that sends
tools/call with a progressToken as the first JSON-RPC message (no preceding server/discover).
- 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.rs — serve_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)
Bug description
When a modern-protocol (2026-07-28) rmcp server receives a non-
initializerequest as its very first message on a stdio transport,serve_server_with_ct_innerhandles that request inline beforeserve_inner's peer-drain loop is started.During that inline handler execution:
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.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, andserve_innernever starts to drain the channel.This affects any client using the 2026-07-28 protocol that skips
server/discoverand sends e.g.tools/callas its first message. The TypeScript SDK client does exactly this (it probesserver/discoveron a short-lived sibling process), so the deadlock is hit on the very first real progress test against an rmcp server.Reproduction
context.peer().send_notification(...)(e.g. a progress notification) before returning its result.tools/callwith aprogressTokenas the first JSON-RPC message (no precedingserver/discover).Sending
server/discover(or any other message) first avoids the bug becauseserve_inneris then running normally by the time the tool handler executes.Related issues
serve_server_with_ct_inner. That issue was fixed by adding an error response for missing_meta, but the structural problem — executing the first request outside the serve loop — remains.Suggested fix
Hand the pre-read first request to
serve_innerinstead of handling it inline:initial_messages: VecDeque<RxJsonRpcMessage<R>>parameter toserve_inner.serve_server_with_ct_inner, when the first message is notinitialize, wrap it as aClientJsonRpcMessage::requestand pass it viaVecDeque::from([first_message])instead of callingservice.handle_request(...).awaitinline.batch_messagesinside the serve loop frominitial_messagesso the pre-fetched request is dispatched in wire order alongside subsequent transport reads.serve_client_with_ct_inner, the post-initialize path) passDefault::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.rs—serve_inneraccepts and seedsinitial_messagescrates/rmcp/src/service/server.rs— modern-path first request routed through the loopcrates/rmcp/src/service/client.rs— call-site update (passes empty deque)All existing upstream regression tests pass against the patched copy.
Environment
e27c5d1)mainbranch