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).
-
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).
-
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.
-
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.)
-
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.
-
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.
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/Discoverwithmodern 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) isacknowledged with
202 Acceptedand then discarded:There is no pending-request registry on that path, and the per-POST
OneshotTransportthe stateless branch runs the handler on has noreceive 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 a202 (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
initializehandshake (any versionbelow 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 itshandler 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
client,server,transport-streamable-http-server,transport-streamable-http-client-reqwest,elicitation(feature names verified against
rmcp-3.4.1/Cargo.toml [features]).19045; both ends in-process over loopback TCP — no proxies, no TLS.
rmcp = "3.2.0",Cargo.lockresolves 3.3.0) against a GooseClaw server pinningrmcp 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 weretaken 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.Control experiment: change ONLY the lifecycle to
ClientLifecycleMode::Initialize(and drop thesupported_protocol_versionsoverride, 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 likeDiscoverhere (that is thegoose 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-28to the server — it returns a bare202 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 withthe 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 timeoutkills the
call_toolfuture — and theserver: sampling answeredline 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 genuinelycannot 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 publishedcrates.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 fromthe request
_metaor theMCP-Protocol-Versionheader and callsuses_legacy_lifecycle(src/service.rs:210-215):!uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version), whereis_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, because
discover_startupstamps the negotiatedversion into
ClientRequestMetadatafor all subsequent requests(
src/service/client.rs:991-995) — is served statelessly, evenwith
legacy_session_mode = true. Aninitializerequest ishard-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, anintermediate 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 a
ClientJsonRpcMessage::Response. It is routedstatelessly (step 1) into the arm quoted above
(
tower.rs:2070, andtower.rs:2071forError) —202 Accepted, body dropped, no request-id lookup, nothing. (Thesibling
Notificationarm 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 yieldexactly 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 secondinbound message (the client's response) can enter the service
loop. So the pending
create_messagefuture waits on a responderthat 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), andLocalSessionManager::accept_messagepushes it into the sessionhandle (
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
github.com/aaif-goose/goose):McpClient::connectdefaults 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).Autoprobesserver/discoverfirst and, onDiscoverOutcome::Modern, skipsinitializeentirely (rmcp src/service/client.rs:799-842, theOk(Ok(DiscoverOutcome::Modern)) => {}arm at 816). goose'scapabilities default to no pinned version
(
crates/goose/src/agents/extension_manager/mod.rs:402-410passes
self.capabilities.protocol_versionthrough; defaultNoneat
mod.rs:449), and the streamable-http connector's only legacyretry 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 moderndiscover. 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), andthey hang as described. goose's lock currently resolves rmcp 3.3.0
(same arm at that version's
tower.rs:2018).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 theexplanatory comment at 622-631) — which forces the
Initializebranch ofMcpClient::connect(
mcp_client.rs:668-675: pinned version belowProtocolVersion::STANDARD_HEADERS(= 2026-07-28,rmcp src/model.rs:178) →ClientLifecycleMode::Initialize).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) butelicitation 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
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:2070resolve from it instead ofreturning a bare 202. This also requires giving the stateless
handler's transport a receive channel for that second inbound
message (the
OneshotTransportattower.rs:2002-2013cannotcurrently accept one —
src/transport.rs:225-231).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.
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.
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
initializehandshake 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 newestpre-2026-07-28 version,
rmcp src/model.rs:171, 175). Verifiedworking in GooseClaw (round 13, audit lines 432-435). Caveat: a
modern-only server (one whose
supported_protocol_versionsexcludeslegacy 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.