Skip to content

streamable-http server: client responses to server-initiated requests (sampling/elicitation/roots) are 202-accepted and silently discarded under the 2026-07-28 protocol — the pending request hangs forever #1321

Description

@ne-tort

Component: rmcp::transport::streamable_http_server (tower.rs, stateless branch)

Version: rmcp 3.4.1 (verified). The same Response(_) => accepted_response() arm exists in 3.3.0 (src/transport/streamable_http_server/tower.rs:2018) and 3.5.0 (.../tower.rs:2345) — present since the stateless/SEP-2567 path landed, not verified end-to-end on anything but 3.4.1.

Summary

A streamable-http client that negotiates the modern (2026-07-28)
protocol — which is what ClientLifecycleMode::Auto/Discover with
modern preferred versions does — gets every subsequent POST served
statelessly. In the stateless branch, a POST carrying a JSON-RPC
response (or error) to a server-initiated request (MCP
sampling/createMessage, elicitation/create, roots/list) is
acknowledged with 202 Accepted and then discarded:

// src/transport/streamable_http_server/tower.rs:2066-2071 (rmcp 3.4.1)
ClientJsonRpcMessage::Notification(_notification) => {
    // ignore
    Ok(accepted_response())
}
ClientJsonRpcMessage::Response(_json_rpc_response) => Ok(accepted_response()),
ClientJsonRpcMessage::Error(_json_rpc_error) => Ok(accepted_response()),

There is no pending-request registry on that path, and the per-POST
OneshotTransport the stateless branch runs the handler on has no
receive channel for a second inbound message at all (details in Root
cause). The server side of a sampling/elicitation round-trip therefore
hangs forever: the tool (or other handler) that issued the
server-initiated request awaits a response the transport dropped.
The client observes success every step of the way — its response POST
is 202 Accepted, and the rmcp client transport explicitly treats a
202 (or empty 200) for a Response/Notification/Error POST as success
(src/transport/common/reqwest/streamable_http_client.rs:263-275,
comment: "Spec requires 202 Accepted for these"). Both sides then
block: the server handler never returns, so the POST's SSE stream
never completes, so the client's tool call never completes either.

With the client on the classic initialize handshake (any version
below 2026-07-28) the SAME code routes through the sessioned branch,
where the response POST is fed into the session channel and reaches
the pending request. That asymmetry is what makes this easy to miss:
the existing end-to-end tests for server-initiated requests run on the
sessioned path — rmcp's own elicitation routing test opens its
connection with initialize/2025-11-25 and its comment says
"2025-11-25 is the latest session-carrying version; SEP-2567 serves
2026-07-28+ statelessly, with no standalone GET stream to test
against" (tests/test_sep_2260_stream_routing.rs:94-95), and its
handler comment says "Never answered: the test only checks the request
is emitted on the right stream" (line 35). No test in the published
crate closes a server-initiated request round-trip over the stateless
path.

Environment

  • rmcp 3.4.1, features client, server,
    transport-streamable-http-server,
    transport-streamable-http-client-reqwest, elicitation
    (feature names verified against rmcp-3.4.1/Cargo.toml [features]).
  • rustc 1.96.1 (31fca3adb 2026-06-26), cargo 1.96.1, Windows 10
    19045; both ends in-process over loopback TCP — no proxies, no TLS.
  • First observed live via goose (workspace rmcp = "3.2.0",
    Cargo.lock resolves 3.3.0) against a GooseClaw server pinning
    rmcp 3.4.1; root-caused by code reading of the published 3.4.1
    crate and confirmed by the live round-13 e2e (below).

Reproduction

A single-file #[tokio::test], no goose involved. (API spellings were
taken from rmcp 3.4.1's own tests; the snippet below was not executed
in the reporting environment — the live evidence is the goose/GooseClaw
observation in "Observed", and the code path is cited line-by-line in
"Root cause" across 3.3.0/3.4.1/3.5.0.) Server: a tool that
asks the client to sample mid-call. Client: connects with the modern
lifecycle and answers sampling/createMessage.

// repro_stateless_discard.rs
// deps: rmcp 3.4.1 { features = ["client", "server",
//   "transport-streamable-http-server",
//   "transport-streamable-http-client-reqwest"] }
//       tokio, tokio-util, axum, reqwest (dev-deps of rmcp itself suffice)
use std::{borrow::Cow, sync::Arc, time::Duration};

use rmcp::{
    ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler,
    model::{
        CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
        CreateMessageRequestParams, CreateMessageResult, ProtocolVersion,
        SamplingMessage,
    },
    service::{RequestContext, RoleClient, RoleServer},
    transport::{
        StreamableHttpClientTransport,
        streamable_http_client::StreamableHttpClientTransportConfig,
        streamable_http_server::{
            StreamableHttpServerConfig, StreamableHttpService,
            session::local::LocalSessionManager,
        },
    },
};
use tokio_util::sync::CancellationToken;

#[derive(Clone)]
struct SamplingServer;

impl ServerHandler for SamplingServer {
    // default would also work (KNOWN_VERSIONS includes 2026-07-28);
    // pinned here so the repro is deterministic
    fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
        Cow::Borrowed(&[ProtocolVersion::V_2026_07_28])
    }

    #[allow(deprecated)] // sampling is SEP-2577-deprecated but still routed
    async fn call_tool(
        &self,
        _request: CallToolRequestParams,
        context: RequestContext<RoleServer>,
    ) -> Result<CallToolResponse, rmcp::ErrorData> {
        println!("server: tool called, issuing sampling/createMessage ...");
        let sampled = context
            .peer
            .create_message(CreateMessageRequestParams::new(
                vec![SamplingMessage::user_text("Should we proceed?")],
                64,
            ))
            .await?; // <-- NEVER RESOLVES on the stateless path
        println!("server: sampling answered: {:?}", sampled.message.content);
        Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
    }
}

#[derive(Clone)]
struct AnsweringClient;

impl ClientHandler for AnsweringClient {
    async fn create_message(
        &self,
        _params: CreateMessageRequestParams,
        _context: RequestContext<RoleClient>,
    ) -> Result<CreateMessageResult, rmcp::ErrorData> {
        println!("client: createMessage received, answering");
        Ok(CreateMessageResult::new(
            SamplingMessage::assistant_text("yes"),
            "repro-model".to_string(),
        ))
    }
}

#[tokio::test]
async fn stateless_sampling_roundtrip_hangs() {
    let ct = CancellationToken::new();
    let service = StreamableHttpService::new(
        move || Ok(SamplingServer),
        Arc::new(LocalSessionManager::default()),
        StreamableHttpServerConfig::default()
            .with_json_response(false)
            .with_sse_keep_alive(None)
            .with_cancellation_token(ct.child_token()),
    );
    let router = axum::Router::new().nest_service("/mcp", service);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://127.0.0.1:{}/mcp", listener.local_addr().unwrap().port());
    tokio::spawn({
        let ct = ct.clone();
        async move {
            let _ = axum::serve(listener, router)
                .with_graceful_shutdown(async move { ct.cancelled_owned().await })
                .await;
        }
    });
    tokio::time::sleep(Duration::from_millis(100)).await;

    let transport = StreamableHttpClientTransport::from_config(
        StreamableHttpClientTransportConfig::with_uri(url),
    );
    // (a) connect with the modern protocol version
    let client = AnsweringClient
        .serve_with_lifecycle(
            transport,
            ClientLifecycleMode::Discover {
                preferred_versions: vec![ProtocolVersion::V_2026_07_28],
            },
        )
        .await
        .expect("discover should succeed");

    // (c) the tool hangs: the client answers, the server never gets it
    let outcome = tokio::time::timeout(
        Duration::from_secs(10),
        client.call_tool(CallToolRequestParams::new("ask".to_string())),
    )
    .await;

    println!("outcome: {:?}", outcome);
    assert!(outcome.is_err(), "expected the tool call to hang");
    ct.cancel();
}

Control experiment: change ONLY the lifecycle to
ClientLifecycleMode::Initialize (and drop the
supported_protocol_versions override, or include 2025-11-25 in it)
— identical server, identical client handler — and the round-trip
completes: the tool returns "done". Auto { preferred_versions: [V_2026_07_28, ...], .. } behaves like Discover here (that is the
goose default; see Impact).

Equivalent raw-HTTP probe (from code reading, not live-tested):
POST any JSON-RPC response body with
MCP-Protocol-Version: 2026-07-28 to the server — it returns a bare
202 with no request-id matching anywhere on that path.

Observed

Live (GooseClaw round 13, 2026-10-02; audit
docs/gooseclaw/audit-2026-10-01-v05.md:418-431): a turn stuck with
the tool call open while the client's sampling completion was POSTed
back and 202-ACCEPTED; the server-side handler waited forever; the
e2e hung until its 60 s timeout. After pinning the client to
2025-11-25 (Initialize → sessioned path) the identical flow —
sampling allow and deny, both approval parks, the sampled completion
returned in the tool result — passed end to end.

Expected from the repro above: server: tool called ...,
client: createMessage received, answering, then the 10 s timeout
kills the call_tool future — and the server: sampling answered
line never prints. (This exact snippet was not run; the behavior
statement is inferred from the verified code path plus the live
goose observation.)

Expected

The client's response to a server-initiated request is delivered to
the pending request, whatever protocol version was negotiated. The
tool call completes with "done". If the stateless path genuinely
cannot support the round-trip, the response POST should fail legibly
(e.g. a 4xx naming the unmatched request id) rather than 202-accept
silently while both ends hang.

Root cause

All citations are rmcp-3.4.1/src/... line numbers from the published
crates.io tarball; in this repo the same files live under
crates/rmcp/src/... (adjust the prefix when reading).

  1. Routing. handle_post splits sessioned vs stateless:
    use_session = self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers)?
    (src/transport/streamable_http_server/tower.rs:1761-1762;
    legacy_session_mode defaults to true, tower.rs:192).
    is_legacy_request (tower.rs:382-439) resolves the version from
    the request _meta or the MCP-Protocol-Version header and calls
    uses_legacy_lifecycle (src/service.rs:210-215):
    !uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version), where
    is_legacy_version is < 2026-07-28 (src/service.rs:204-208).
    So ANY request naming 2026-07-28 — which is every request a modern
    client sends, because discover_startup stamps the negotiated
    version into ClientRequestMetadata for all subsequent requests
    (src/service/client.rs:991-995) — is served statelessly, even
    with legacy_session_mode = true. An initialize request is
    hard-routed to the sessioned path regardless of the version it
    names (tower.rs:401-411).

  2. Stateless serving. The stateless branch gives each POST its own
    OneshotTransport and serves the handler on it
    (tower.rs:2000-2013,
    serve_directly_with_ct(NegotiatingStatelessHttpService(service), transport, peer_info, request_ct)). The handler's messages
    (including a server-initiated request) stream back on the POST's
    SSE response (tower.rs:2018-2061 — with json_response, an
    intermediate request forces the SSE fallback exactly to "preserve
    the complete message sequence"). So the sampling/elicitation
    request itself DOES reach the client.

  3. The discard. The client's answer arrives as a new POST whose
    body is a ClientJsonRpcMessage::Response. It is routed
    statelessly (step 1) into the arm quoted above
    (tower.rs:2070, and tower.rs:2071 for Error) — 202 Accepted, body dropped, no request-id lookup, nothing. (The
    sibling Notification arm at tower.rs:2066-2068 is fine —
    notifications are fire-and-forget; responses are not.)

  4. Why the handler can never be woken even by a fix that only
    routes the message.
    OneshotTransport's receive side can yield
    exactly ONE inbound message — the POSTed request it was created
    with — and then parks on a termination semaphore that is only
    permitted when the handler SENDS its final response
    (src/transport.rs:174-237, receive() at 225-231,
    send() at 208-223). There is no channel by which a second
    inbound message (the client's response) can enter the service
    loop. So the pending create_message future waits on a responder
    that can never fire; the handler, the SSE stream, and the client's
    call_tool all hang.

  5. The contrast that proves intent. On the sessioned path the
    same message is routed into the session:
    ClientJsonRpcMessage::Notification(_) | Response(_) | Error(_) => self.session_manager.accept_message(&session_id, message) ... Ok(accepted_response()) (tower.rs:1828-1837), and
    LocalSessionManager::accept_message pushes it into the session
    handle (src/transport/streamable_http_server/session/local.rs:147-158),
    where the session worker's transport feeds it to the service loop
    and the pending request resolves. Initialize (any pre-2026-07-28
    version) therefore works; discover/modern does not.

Impact

  • goose (github.com/aaif-goose/goose): McpClient::connect
    defaults to the Auto lifecycle with the modern version preferred
    when no protocol version is pinned —
    ClientLifecycleMode::Auto { preferred_versions: [ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25], legacy_version: Some(ProtocolVersion::V_2025_11_25) }
    (crates/goose/src/agents/mcp_client.rs:667-689). Auto probes
    server/discover first and, on DiscoverOutcome::Modern, skips
    initialize entirely (rmcp src/service/client.rs:799-842, the
    Ok(Ok(DiscoverOutcome::Modern)) => {} arm at 816). goose's
    capabilities default to no pinned version
    (crates/goose/src/agents/extension_manager/mod.rs:402-410
    passes self.capabilities.protocol_version through; default None
    at mod.rs:449), and the streamable-http connector's only legacy
    retry triggers on an empty/unanswered discover
    (crates/goose/src/agents/extension_manager/streamable_http.rs:231-263
    — retry only on "empty sse stream" or
    ConnectionClosed("discover response")), not on a successful modern
    discover. So every goose deployment talking to an rmcp-served
    streamable-http server silently loses the sampling / elicitation /
    roots round-trips — goose implements all three client-side
    (crates/goose/src/agents/mcp_client.rs:365-448:
    create_message routing to the sampling handler, list_roots), and
    they hang as described. goose's lock currently resolves rmcp 3.3.0
    (same arm at that version's tower.rs:2018).
  • GooseClaw (the reporter): hit this as a 60 s e2e hang in round
    13 and worked around it by pinning
    ExtensionManagerCapabilities.protocol_version = Some(ProtocolVersion::V_2025_11_25) in its app assembly
    (server/claw-app/src/lib.rs:617-633, pin at 632 with the
    explanatory comment at 622-631) — which forces the
    Initialize branch of McpClient::connect
    (mcp_client.rs:668-675: pinned version below
    ProtocolVersion::STANDARD_HEADERS (= 2026-07-28,
    rmcp src/model.rs:178) → ClientLifecycleMode::Initialize).
  • Any client that negotiates 2026-07-28 with an rmcp
    streamable-http server and relies on server-initiated requests
    (sampling, elicitation, roots) — i.e. the entire
    modern-protocol round-trip surface. Note sampling and roots are
    SEP-2577-deprecated (src/service/server.rs:846-849, 884-887) but
    elicitation is not (create_elicitation at src/service/server.rs:896),
    and all three share the identical transport path, so this is not
    wontfix-able on deprecation grounds alone.

Suggested directions

  • Route responses/errors for server-initiated requests on the
    stateless path: a pending-request registry keyed by request id (the
    registry can be process-global or keyed by the originating
    POST/stream), and have tower.rs:2070 resolve from it instead of
    returning a bare 202. This also requires giving the stateless
    handler's transport a receive channel for that second inbound
    message (the OneshotTransport at tower.rs:2002-2013 cannot
    currently accept one — src/transport.rs:225-231).
  • Minimum viable legibility fix if full routing is out of scope:
    reject a stateless Response/Error POST whose id matches no pending
    request with a 4xx that names the id, instead of the silent 202 —
    both sides currently hang with zero signal.
  • Alternatively, document the modern-protocol limitation on the
    server-initiated request surface, and make the server refuse to
    advertise sampling/elicitation/roots capabilities when it is going
    to serve a client statelessly, so the capability negotiation fails
    loudly rather than the tool hanging.
  • Whatever the fix, add the missing test: close a full
    server-initiated request round-trip over the STATELESS path
    (discover/2026-07-28 + response POST). Today the only end-to-end
    elicitation test runs the 2025-11-25 sessioned path and explicitly
    never answers the request
    (tests/test_sep_2260_stream_routing.rs:35, 94-95).

Workarounds

Pin an older protocol version on the CLIENT so the initialize
handshake runs and the connection is served sessioned: e.g. goose's
ExtensionManagerCapabilities.protocol_version = Some(ProtocolVersion::V_2025_11_25) (2025-11-25 is the newest
pre-2026-07-28 version, rmcp src/model.rs:171, 175). Verified
working in GooseClaw (round 13, audit lines 432-435). Caveat: a
modern-only server (one whose supported_protocol_versions excludes
legacy versions) will refuse that handshake, so the workaround trades
the hang for a connection failure on such servers — it is a
workaround, not a fix.


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

    P1High: significant functionality gap or spec violationT-transportTransport 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