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
I maintainer di solito rispondono entro 3 giorni
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- rust
- Ambito
- api, backend-api-design
Direzione di ricerca
Read crates/rmcp/src/transport/streamable_http_server/tower.rs, especially handle_post and the stateless branch, then compare it with the sessioned response-routing path. Review tests/test_sep_2260_stream_routing.rs and add coverage for a complete server-initiated request round-trip on the stateless path. Done means the response reaches the pending request and the tool call completes, or the POST returns a clear error instead of silently accepting a discarded response.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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 againstrmcp-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.lockresolves 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).
-
Routing.
handle_postsplits 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_modedefaults to true,tower.rs:192).
is_legacy_request(tower.rs:382-439) resolves the version from
the request_metaor theMCP-Protocol-Versionheader and calls
uses_legacy_lifecycle(src/service.rs:210-215):
!uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version), where
is_legacy_versionis< 2026-07-28(src/service.rs:204-208).
So ANY request naming 2026-07-28 — which is every request a modern
client sends, becausediscover_startupstamps the negotiated
version intoClientRequestMetadatafor all subsequent requests
(src/service/client.rs:991-995) — is served statelessly, even
withlegacy_session_mode = true. Aninitializerequest is
hard-routed to the sessioned path regardless of the version it
names (tower.rs:401-411). -
Stateless serving. The stateless branch gives each POST its own
OneshotTransportand 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— withjson_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. -
The discard. The client's answer arrives as a new POST whose
body is aClientJsonRpcMessage::Response. It is routed
statelessly (step 1) into the arm quoted above
(tower.rs:2070, andtower.rs:2071forError) —202 Accepted, body dropped, no request-id lookup, nothing. (The
siblingNotificationarm attower.rs:2066-2068is fine —
notifications are fire-and-forget; responses are not.) -
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 pendingcreate_messagefuture waits on a responder
that can never fire; the handler, the SSE stream, and the client's
call_toolall hang. -
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_messagepushes 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).Autoprobes
server/discoverfirst and, onDiscoverOutcome::Modern, skips
initializeentirely (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
passesself.capabilities.protocol_versionthrough; defaultNone
atmod.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_messagerouting 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'stower.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
Initializebranch ofMcpClient::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_elicitationatsrc/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 havetower.rs:2070resolve 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 (theOneshotTransportattower.rs:2002-2013cannot
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.
- Lingua principale
- Rust
- Stelle
- 4k
- Fork
- 654
- Merge medio
- 4g 2h
- PR unite (30g)
- 40
Preparare l'ambiente
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di modelcontextprotocol/rust-sdk
-
P3 question T-documentation T-enhancement
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 86/100
modelcontextprotocol/rust-sdk#1155 ·
I maintainer di solito rispondono entro 3 giorni
-
bug P1 ready for work T-transport
Difficoltà 4/5 3-5 giorni Idoneità per principianti 55/100
modelcontextprotocol/rust-sdk#1325 ·
I maintainer di solito rispondono entro 3 giorni
-
Bound pre-lifecycle bootstrap attempts in server initializationForse già presa @DaleSeo l’ha presa 3 giorni fa. Apertaenhancement P2 T-service T-transport
modelcontextprotocol/rust-sdk#1315 · 1 assegnatario ·
I maintainer di solito rispondono entro 3 giorni
-
ProgressDispatcher: a slow progress subscriber blocks subscribe() and delivery for other tokensApertabug P1 ready for work T-handler
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
modelcontextprotocol/rust-sdk#1312 ·
I maintainer di solito rispondono entro 3 giorni
-
transport::stdio() runs every read and write on tokio's blocking pool, which caps stdio throughputForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertaenhancement P2 T-transport
Difficoltà 4/5 3-5 giorni Idoneità per principianti 58/100
modelcontextprotocol/rust-sdk#1301 · 1 commento ·
I maintainer di solito rispondono entro 3 giorni
Tutte le issue di modelcontextprotocol/rust-sdk
Issue simili
-
feature request good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
TabularisDB/tabularis#853 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
andrewdavidmackenzie/jonesy#267 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
I maintainer di solito rispondono entro 4 giorni
-
`future_into_py` loses the original panic messageForse già presa @Danipulok l’ha presa oggi. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 90/100
PyO3/pyo3-async-runtimes#91 ·
-
Signals (Failure Detector): a tool call and its own execution are reported as a repeated callAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
I maintainer di solito rispondono entro 1 giorno