From dc46fa53dd499d9a7c541b98060edd3a8cc418e9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 14 Oct 2026 08:17:08 +0000 Subject: [PATCH 1/2] chore(deps): upgrade rmcp to 3.5 rmcp 3.5 moves ProtocolVersion::LATEST to 2026-07-28, a revision with no initialize handshake. Initialize negotiation and the stateless request context stay on LATEST_WITH_INITIALIZE (2025-11-25): a supported handshake revision is echoed, a handshake-less or unknown one negotiates down. The wire tests now pin what the default rmcp client requests (LATEST), the downgrade, the echo of a supported revision on both the rmcp and raw paths, and that stateless results omit the 2026-07-28 result fields. --- Cargo.lock | 4 +- crates/tracedecay-mcp/Cargo.toml | 4 +- crates/tracedecay-mcp/src/server/protocol.rs | 4 +- crates/tracedecay-mcp/src/server/rmcp.rs | 6 ++- crates/tracedecay/Cargo.toml | 4 +- crates/tracedecay/src/mcp/server/rmcp.rs | 49 +++++++++++++++---- .../mcp_server_test/protocol_test.rs | 7 ++- 7 files changed, 58 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2901607e90..37b42e62c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4583,9 +4583,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.4.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23c62fe489ac1d401ab32688cfacac3737a8978dc3343e5361464c7724fd3cb" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" dependencies = [ "chrono", "futures", diff --git a/crates/tracedecay-mcp/Cargo.toml b/crates/tracedecay-mcp/Cargo.toml index 0b51813923..093a492737 100644 --- a/crates/tracedecay-mcp/Cargo.toml +++ b/crates/tracedecay-mcp/Cargo.toml @@ -25,7 +25,7 @@ gix.workspace = true glob = "0.3" hex = "0.4" libc = "0.2" -rmcp = { version = "3.0.1", default-features = false, features = ["server"] } +rmcp = { version = "3.5.1", default-features = false, features = ["server"] } # The multi-root execute tool derives its input schema from the contracts DTO. serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -73,7 +73,7 @@ tree-sitter = "0.26" url = "2" [dev-dependencies] -rmcp = { version = "3.0.1", default-features = false, features = ["client", "server", "transport-async-rw"] } +rmcp = { version = "3.5.1", default-features = false, features = ["client", "server", "transport-async-rw"] } tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracedecay-code-index-runtime = { path = "../tracedecay-code-index-runtime", features = ["test-helpers"] } diff --git a/crates/tracedecay-mcp/src/server/protocol.rs b/crates/tracedecay-mcp/src/server/protocol.rs index 3ba9ee7f14..a8f518672b 100644 --- a/crates/tracedecay-mcp/src/server/protocol.rs +++ b/crates/tracedecay-mcp/src/server/protocol.rs @@ -48,10 +48,10 @@ pub fn initialize_result( instructions: &str, requested_protocol: Option<&str>, ) -> Value { - let protocol = ProtocolVersion::known_up_to(&ProtocolVersion::LATEST) + let protocol = ProtocolVersion::known_up_to(&ProtocolVersion::LATEST_WITH_INITIALIZE) .iter() .find(|known| Some(known.as_str()) == requested_protocol) - .unwrap_or(&ProtocolVersion::LATEST); + .unwrap_or(&ProtocolVersion::LATEST_WITH_INITIALIZE); json!({ "protocolVersion": protocol, "capabilities": { diff --git a/crates/tracedecay-mcp/src/server/rmcp.rs b/crates/tracedecay-mcp/src/server/rmcp.rs index 676b204fa8..bf4a6b3ce2 100644 --- a/crates/tracedecay-mcp/src/server/rmcp.rs +++ b/crates/tracedecay-mcp/src/server/rmcp.rs @@ -592,7 +592,9 @@ pub fn attach_stateless_request_context(request: &mut JsonRpcRequest) -> bool { return false; }; let mut context = RequestMetaObject::new(); - context.set_protocol_version(ProtocolVersion::LATEST); + // `LATEST` (2026-07-28) has no initialize handshake, and stamping it would add + // resultType/ttlMs/cacheScope that strict 2025-11-25 clients reject. + context.set_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE); context.set_client_capabilities(ClientCapabilities::default()); let mut attached = false; for (key, value) in context.0.0 { @@ -639,7 +641,7 @@ const MALFORMED_INITIALIZE_MESSAGE: &str = "initialize params are missing or mal /// request carried SEP-2575 `_meta` instead of `initialize` is an inline /// lifecycle peer, and `rmcp`'s blanket `Service` impl refuses `ping` there /// with method-not-found (or invalid params without `_meta`) before -/// `ServerHandler::ping` runs (rmcp 3.4, `handler/server.rs`). MCP requires +/// `ServerHandler::ping` runs (rmcp 3.5, `handler/server.rs`). MCP requires /// an empty result to a ping at any time; hosts use it as a liveness probe. struct GuardedHandshakeTransport> { inner: T, diff --git a/crates/tracedecay/Cargo.toml b/crates/tracedecay/Cargo.toml index bc0de2fd03..b24f8c245d 100644 --- a/crates/tracedecay/Cargo.toml +++ b/crates/tracedecay/Cargo.toml @@ -292,7 +292,7 @@ zeroize = "1.9.0" keyring = { version = "4.1.5", features = ["v1"] } tempfile = "3" futures-util = "0.3.33" -rmcp = { version = "3.0.1", default-features = false, features = ["server"] } +rmcp = { version = "3.5.1", default-features = false, features = ["server"] } # Opt-in. Cargo rejects optional dev-dependencies, and an unconditional one # links the evaluator into every test target. Default and `production` builds # leave it off, so the shipped CLI does not link it. @@ -315,7 +315,7 @@ regex = "1.12.3" filetime = "0.2" shell-words = "1.1" socket2 = "0.6" -rmcp = { version = "3.0.1", default-features = false, features = ["client", "server", "transport-async-rw"] } +rmcp = { version = "3.5.1", default-features = false, features = ["client", "server", "transport-async-rw"] } criterion = { version = "0.5", features = ["async_tokio"] } # test-util enables tokio::test(start_paused = true) so timer-driven unit # tests (daemon restart-grace windows) run on virtual time instead of real diff --git a/crates/tracedecay/src/mcp/server/rmcp.rs b/crates/tracedecay/src/mcp/server/rmcp.rs index 525a65a90c..0a1f806790 100644 --- a/crates/tracedecay/src/mcp/server/rmcp.rs +++ b/crates/tracedecay/src/mcp/server/rmcp.rs @@ -45,7 +45,8 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use rmcp::model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ReadResourceRequestParams, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientConfig, ProtocolVersion, + ReadResourceRequestParams, }; use rmcp::service::ServiceRole; use rmcp::transport::{IntoTransport, Transport}; @@ -132,7 +133,7 @@ mod tests { } struct RmcpWireFixture { - client: rmcp::service::RunningService, + client: rmcp::service::RunningService, server: Arc, client_messages: Arc>>, server_messages: Arc>>, @@ -142,7 +143,7 @@ mod tests { } impl RmcpWireFixture { - async fn start() -> Self { + async fn start(client_config: ClientConfig) -> Self { tracedecay_project::product_runtime::register_fixture_product_runtime(); let (cg, repo, authority) = crate::mcp::server::writer_test_support::init_indexed_repo().await; @@ -178,7 +179,10 @@ mod tests { IntoTransport::::into_transport(client_io), Arc::clone(&client_messages), ); - let client = ().serve(client_transport).await.expect("initialize RMCP client"); + let client = client_config + .serve(client_transport) + .await + .expect("initialize RMCP client"); Self { client, server, @@ -362,6 +366,12 @@ mod tests { let listed: Value = serde_json::from_str(&line).expect("tools/list frame"); assert_eq!(listed["id"], json!(1), "{line}"); assert!(listed["result"]["tools"].is_array(), "{line}"); + for field in ["resultType", "ttlMs", "cacheScope"] { + assert!( + listed["result"].get(field).is_none(), + "stateless requests speak a handshake revision, so {field} is absent: {line}" + ); + } for (request, id) in [ ( @@ -398,18 +408,23 @@ mod tests { #[tokio::test] async fn rmcp_wire_matrix_matches_raw_dispatch_initialize_tools_and_resources() { - let fixture = RmcpWireFixture::start().await; - let requested_protocol = fixture + let fixture = RmcpWireFixture::start(ClientConfig::default()).await; + let requested = fixture .last_request() .params .as_ref() .and_then(|params| params.get("protocolVersion")) .cloned() .expect("RMCP client initialize names a protocol version"); + assert_eq!( + requested, + json!(ProtocolVersion::LATEST), + "a default RMCP client asks for the newest revision even over initialize", + ); assert_eq!( fixture.last_response()["result"]["protocolVersion"], - requested_protocol, - "a supported client version is echoed on the RMCP wire", + json!(ProtocolVersion::LATEST_WITH_INITIALIZE), + "a revision without a handshake negotiates down to the newest one that has it", ); assert_eq!( fixture.last_response()["result"]["_meta"]["tracedecayInitializeRoute"], @@ -608,11 +623,27 @@ mod tests { fixture.shutdown().await; } + #[tokio::test] + async fn rmcp_initialize_echoes_a_supported_handshake_revision() { + let fixture = RmcpWireFixture::start( + ClientConfig::default().with_protocol_version(ProtocolVersion::V_2025_06_18), + ) + .await; + assert_eq!( + fixture.last_response()["result"]["protocolVersion"], + json!("2025-06-18"), + ); + fixture + .assert_last_response_matches_raw_dispatch(true) + .await; + fixture.shutdown().await; + } + /// `rmcp` moves wire `params._meta` into the request context; the caller /// deadline it carries must still reach dispatch as on the raw path. #[tokio::test] async fn rmcp_tool_call_honours_the_request_meta_caller_deadline() { - let fixture = RmcpWireFixture::start().await; + let fixture = RmcpWireFixture::start(ClientConfig::default()).await; let mut params = CallToolRequestParams::new("tracedecay_status").with_arguments( json!({"admission_only": true, "format": "json"}) .as_object() diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs index 4c41ac7138..0c67663341 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs @@ -16,7 +16,12 @@ use tracedecay_runtime_core::tracedecay::current_timestamp; #[tokio::test] async fn test_initialize_negotiates_legacy_and_unknown_client_versions() { - for (requested, expected) in [("2024-11-05", "2024-11-05"), ("2099-01-01", "2025-11-25")] { + for (requested, expected) in [ + ("2024-11-05", "2024-11-05"), + ("2025-11-25", "2025-11-25"), + ("2026-07-28", "2025-11-25"), + ("2099-01-01", "2025-11-25"), + ] { let (server, _dir) = setup_server().await; let mut initialize: Value = serde_json::from_str(&spec_initialize_request(json!(1))).unwrap(); From 3133aee0388b86bc449e4e9aed940e3c4e7af9f3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 14 Oct 2026 18:59:30 +0000 Subject: [PATCH 2/2] fix(mcp): follow rmcp 3.5's lifecycle in the session loop rmcp 3.5 sets ProtocolVersion::LATEST to 2026-07-28, which has no initialize handshake. Initialize negotiation, its default answer and the stateless request context use LATEST_WITH_INITIALIZE, so a default 3.5 client that asks for 2026-07-28 over initialize gets 2025-11-25. Before a lifecycle is chosen, the session now answers a request with missing context or an unsupported inline version and stays open, and answers a server/discover probe without choosing the inline lifecycle. A client that probes first can still fall back to initialize. --- crates/tracedecay-mcp/src/server/protocol.rs | 9 +- crates/tracedecay-mcp/src/server/rmcp.rs | 6 +- crates/tracedecay-mcp/src/server/session.rs | 34 ++++-- crates/tracedecay-mcp/tests/server_rmcp.rs | 121 +++++++++++++++++-- 4 files changed, 151 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-mcp/src/server/protocol.rs b/crates/tracedecay-mcp/src/server/protocol.rs index c1df7fd4d3..8924aa1f72 100644 --- a/crates/tracedecay-mcp/src/server/protocol.rs +++ b/crates/tracedecay-mcp/src/server/protocol.rs @@ -48,7 +48,10 @@ pub fn initialize_result( instructions: &str, requested_protocol: Option<&str>, ) -> Value { - let protocol = requested_protocol.map_or(ProtocolVersion::LATEST, negotiate_protocol_version); + let protocol = requested_protocol.map_or( + ProtocolVersion::LATEST_WITH_INITIALIZE, + negotiate_protocol_version, + ); json!({ "protocolVersion": protocol, "capabilities": { @@ -69,11 +72,11 @@ pub fn initialize_result( /// known revision that still has an `initialize` handshake, else the newest /// such revision (`2026-07-28` replaced the handshake with per-request context). pub(crate) fn negotiate_protocol_version(requested: &str) -> ProtocolVersion { - ProtocolVersion::known_up_to(&ProtocolVersion::LATEST) + ProtocolVersion::known_up_to(&ProtocolVersion::LATEST_WITH_INITIALIZE) .iter() .find(|known| known.as_str() == requested) .cloned() - .unwrap_or(ProtocolVersion::LATEST) + .unwrap_or(ProtocolVersion::LATEST_WITH_INITIALIZE) } /// The protocol version a request names in its own `params._meta`. diff --git a/crates/tracedecay-mcp/src/server/rmcp.rs b/crates/tracedecay-mcp/src/server/rmcp.rs index 59c688a103..7e7ad793ed 100644 --- a/crates/tracedecay-mcp/src/server/rmcp.rs +++ b/crates/tracedecay-mcp/src/server/rmcp.rs @@ -557,7 +557,9 @@ pub fn attach_stateless_request_context(request: &mut JsonRpcRequest) -> bool { return false; }; let mut context = RequestMetaObject::new(); - context.set_protocol_version(ProtocolVersion::LATEST); + // `LATEST` (2026-07-28) has no initialize handshake, and stamping it would add + // resultType/ttlMs/cacheScope that strict 2025-11-25 clients reject. + context.set_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE); context.set_client_capabilities(ClientCapabilities::default()); let mut attached = false; for (key, value) in context.0.0 { @@ -597,7 +599,7 @@ where .with_server_info(Implementation::new("tracedecay", self.build_version)) } - /// Answers one request under `rmcp` 3.4's server request policy + /// Answers one request under `rmcp` 3.5's server request policy /// (`handler/server.rs`), narrowed to the methods this server implements. pub(crate) async fn handle_request( &self, diff --git a/crates/tracedecay-mcp/src/server/session.rs b/crates/tracedecay-mcp/src/server/session.rs index 735e085f46..cb00402b48 100644 --- a/crates/tracedecay-mcp/src/server/session.rs +++ b/crates/tracedecay-mcp/src/server/session.rs @@ -3,7 +3,7 @@ //! Every request runs inside the connection future, never on a task of its //! own: a scope the daemon installs around the connection (admission parking) //! covers every handler, and no executor is named here. The protocol policy -//! mirrors `rmcp` 3.4's server (`service/server.rs`, `handler/server.rs`), with +//! mirrors `rmcp` 3.5's server (`service/server.rs`, `handler/server.rs`), with //! `rmcp` kept only for its wire model types. use std::any::Any; @@ -33,10 +33,11 @@ const MALFORMED_INITIALIZE_MESSAGE: &str = "initialize params are missing or mal /// Where a connection stands in the MCP lifecycle. #[derive(Clone)] pub(crate) enum SessionState { - /// Only pings and refused handshakes have arrived. + /// Only pings, refused openers, and `server/discover` probes have arrived. Opening, - /// The first request carried SEP-2575 per-request client context instead - /// of an `initialize`; every later request must carry it too. + /// The first request past any discover probe carried SEP-2575 per-request + /// client context instead of an `initialize`; every later request must + /// carry it too. Inline, /// `initialize` agreed on this protocol version. Initialized(ProtocolVersion), @@ -191,11 +192,30 @@ impl Session<'_, C> { let request = match request { ClientRequest::InitializeRequest(initialize) => initialize.params, request => { + // Each refusal here leaves the session open, and a discover + // probe chooses no lifecycle, so a client that probes first + // can still fall back to `initialize`. let missing = meta.missing_required_keys(&ProtocolVersion::V_2026_07_28); - if !missing.is_empty() { + let Some(requested) = meta.protocol_version().filter(|_| missing.is_empty()) else { let refusal = missing_request_context_error(&missing); - self.answer(id, Err(refusal.clone())).await?; - return Err(opening_failed(&refusal)); + return Ok(self.answer(id, Err(refusal)).await?); + }; + if matches!(request, ClientRequest::DiscoverRequest(_)) { + let context = McpRequestContext { + id: id.clone(), + cancellation: CancellationToken::new(), + meta, + session: SessionState::Opening, + }; + let discovered = self.adapter.handle_request(request, context).await; + return Ok(self.answer(id, discovered).await?); + } + if !ProtocolVersion::KNOWN_VERSIONS.contains(&requested) { + let refusal = ErrorData::unsupported_protocol_version( + requested, + ProtocolVersion::KNOWN_VERSIONS, + ); + return Ok(self.answer(id, Err(refusal)).await?); } self.state = SessionState::Inline; self.start_request_with_meta(id, request, meta); diff --git a/crates/tracedecay-mcp/tests/server_rmcp.rs b/crates/tracedecay-mcp/tests/server_rmcp.rs index 401b4ba31e..75afc1a043 100644 --- a/crates/tracedecay-mcp/tests/server_rmcp.rs +++ b/crates/tracedecay-mcp/tests/server_rmcp.rs @@ -6,7 +6,7 @@ use rmcp::model::{ ClientCapabilities, ErrorCode, PaginatedRequestParams, ProtocolVersion, RequestMetaObject, }; use rmcp::transport::IntoTransport; -use rmcp::{RoleClient, ServiceExt}; +use rmcp::{ClientLifecycleMode, ClientServiceExt, RoleClient, ServiceExt}; use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tracedecay_daemon_protocol::{BrokerListener, DaemonEndpoint, default_loopback_endpoint}; @@ -234,6 +234,56 @@ async fn rmcp_client_handshake_typed_list_and_cancellation_share_the_dispatch_co serving.await.expect("join RMCP server"); } +/// A default `rmcp` 3.5 client sends `initialize` naming 2026-07-28, a revision +/// without a handshake, so the session settles on the newest one that has it. +/// An auto-lifecycle client probes `server/discover` and stays per-request. +#[tokio::test] +async fn rmcp_3_5_clients_open_a_session_on_either_lifecycle() { + let context = new_context(); + for (lifecycle, negotiated) in [ + ( + ClientLifecycleMode::Initialize, + ProtocolVersion::LATEST_WITH_INITIALIZE, + ), + ( + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: None, + }, + ProtocolVersion::V_2026_07_28, + ), + ] { + let adapter = RmcpConnectionAdapter::new(Arc::clone(&context), false, None, None) + .expect("RMCP adapter"); + let (transport, client_io) = broker_pair().await; + let serving = tokio::spawn(async move { + adapter + .serve(transport, std::future::pending()) + .await + .expect("serve MCP session"); + }); + let mut client = () + .serve_with_lifecycle( + IntoTransport::::into_transport(client_io), + lifecycle.clone(), + ) + .await + .expect("open RMCP client session"); + assert_eq!( + client.peer_info().expect("server info").protocol_version, + negotiated, + "{lifecycle:?}" + ); + let tools = client + .list_tools(None::) + .await + .expect("typed tools/list"); + assert!(tools.tools.is_empty(), "{lifecycle:?}"); + client.close().await.expect("close RMCP client"); + serving.await.expect("join RMCP server"); + } +} + tokio::task_local! { static CONNECTION_SCOPE: &'static str; } @@ -380,13 +430,13 @@ async fn initialize_naming_a_per_request_revision_negotiates_the_newest_handshak assert_eq!( initialized["result"]["protocolVersion"], - json!(ProtocolVersion::LATEST.as_str()), + json!(ProtocolVersion::LATEST_WITH_INITIALIZE.as_str()), "{initialized}" ); } #[tokio::test] -async fn an_inline_opener_without_full_request_context_is_refused_and_closed() { +async fn refused_inline_openers_leave_the_session_open_for_initialize() { let context = new_context(); let mut session = open_session(&context).await; let mut partial = request_context(ProtocolVersion::V_2026_07_28); @@ -398,7 +448,6 @@ async fn an_inline_opener_without_full_request_context_is_refused_and_closed() { let refused = session .exchange(&request_frame(1, "tools/list", json!({}), Some(partial))) .await; - assert_eq!(refused["error"]["code"], json!(-32602), "{refused}"); assert!( refused["error"]["message"] @@ -406,10 +455,68 @@ async fn an_inline_opener_without_full_request_context_is_refused_and_closed() { .is_some_and(|message| message.starts_with("request _meta is missing")), "{refused}" ); + + let unsupported = session + .exchange(&request_frame( + 2, + "tools/list", + json!({}), + Some(request_context( + serde_json::from_value(json!("1999-01-01")).expect("version"), + )), + )) + .await; assert_eq!( - session.recv().await, - None, - "the refused opener ends the session" + unsupported["error"]["code"], + json!(ErrorCode::UNSUPPORTED_PROTOCOL_VERSION.0), + "{unsupported}" + ); + + let initialized = session.exchange(&initialize_frame(3, "2025-06-18")).await; + assert_eq!( + initialized["result"]["protocolVersion"], + json!("2025-06-18"), + "{initialized}" + ); +} + +/// An `rmcp` 3.5 auto-lifecycle client probes `server/discover` before it +/// falls back to `initialize`; the probe must not commit the session to the +/// per-request lifecycle. +#[tokio::test] +async fn a_discover_probe_leaves_the_session_open_for_initialize() { + let context = new_context(); + let mut session = open_session(&context).await; + + let discovered = session + .exchange(&request_frame( + 1, + "server/discover", + json!({}), + Some(request_context(ProtocolVersion::V_2026_07_28)), + )) + .await; + assert!( + discovered["result"]["supportedVersions"] + .as_array() + .is_some_and(|versions| versions.contains(&json!("2025-06-18"))), + "{discovered}" + ); + + let initialized = session.exchange(&initialize_frame(2, "2025-06-18")).await; + assert_eq!( + initialized["result"]["protocolVersion"], + json!("2025-06-18"), + "{initialized}" + ); + + let listed = session + .exchange(&request_frame(3, "tools/list", json!({}), None)) + .await; + assert_eq!( + listed["result"], + json!({"tools": []}), + "the initialized session keeps the legacy lifecycle: {listed}" ); }