diff --git a/Cargo.lock b/Cargo.lock index b4c36b106e..b0a89504b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4757,9 +4757,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-global-db/src/observation/schema.rs b/crates/tracedecay-global-db/src/observation/schema.rs index cfdf53add6..1b9b2a8867 100644 --- a/crates/tracedecay-global-db/src/observation/schema.rs +++ b/crates/tracedecay-global-db/src/observation/schema.rs @@ -1,6 +1,6 @@ -use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor}; #[cfg(test)] use tracedecay_runtime_core::db::engine::params; +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor}; use tracedecay_runtime_core::db::schema_version; use super::super::{global_db_operation_error, global_db_operation_message}; @@ -89,7 +89,9 @@ async fn migration_recorded( schema_version::read_version(conn, migration) .await .map(|version| version.is_some()) - .map_err(|error| global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string())) + .map_err(|error| { + global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string()) + }) } async fn record_marker( @@ -98,7 +100,9 @@ async fn record_marker( ) -> tracedecay_domain::errors::Result<()> { schema_version::record_version(conn, migration, 1) .await - .map_err(|error| global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string())) + .map_err(|error| { + global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string()) + }) } async fn observation_rows_exist( @@ -115,8 +119,7 @@ async fn observation_rows_exist( } /// Canonical observation-authority DDL. -const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str = - "CREATE TABLE IF NOT EXISTS sanitization_receipts ( +const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str = "CREATE TABLE IF NOT EXISTS sanitization_receipts ( receipt_id TEXT PRIMARY KEY, sanitizer_version TEXT NOT NULL, payload_digest TEXT NOT NULL, @@ -246,9 +249,11 @@ const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str = pub async fn ensure_observation_schema( conn: &(impl Executor + Sync), ) -> tracedecay_domain::errors::Result> { - schema_version::retire_named_ledgers(conn).await.map_err(|error| { - global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string()) - })?; + schema_version::retire_named_ledgers(conn) + .await + .map_err(|error| { + global_db_operation_message(OBSERVATION_SCHEMA_OPERATION, error.to_string()) + })?; let table_preexisted = observation_table_exists(conn).await?; tracedecay_runtime_core::db::retrieval_anchor_schema::install_retrieval_anchor_schema( conn, diff --git a/crates/tracedecay-global-db/src/schema_stages.rs b/crates/tracedecay-global-db/src/schema_stages.rs index b47d4d27c9..150393815e 100644 --- a/crates/tracedecay-global-db/src/schema_stages.rs +++ b/crates/tracedecay-global-db/src/schema_stages.rs @@ -859,10 +859,7 @@ async fn install_registered_schema_stage_sequence( tracedecay_runtime_core::db::schema_version::retire_named_ledgers(transaction) .await .map_err(|error| { - global_db_operation_message( - "adopt store schema version", - error.to_string(), - ) + global_db_operation_message("adopt store schema version", error.to_string()) })?; let is_fresh = configuration_fresh.is_some(); configuration::ensure_configuration_schema(transaction, configuration_fresh) diff --git a/crates/tracedecay-mcp/Cargo.toml b/crates/tracedecay-mcp/Cargo.toml index 344b79f022..f6c1d48eb3 100644 --- a/crates/tracedecay-mcp/Cargo.toml +++ b/crates/tracedecay-mcp/Cargo.toml @@ -26,7 +26,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" @@ -74,7 +74,7 @@ tree-sitter = "0.27" 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 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}" ); } diff --git a/crates/tracedecay/Cargo.toml b/crates/tracedecay/Cargo.toml index 606d8486e6..295f82fdd5 100644 --- a/crates/tracedecay/Cargo.toml +++ b/crates/tracedecay/Cargo.toml @@ -294,7 +294,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. @@ -316,7 +316,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.8", 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 e0b4fef736..cd87bebcde 100644 --- a/crates/tracedecay/src/mcp/server/rmcp.rs +++ b/crates/tracedecay/src/mcp/server/rmcp.rs @@ -43,7 +43,8 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use rmcp::model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ReadResourceRequestParams, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientConfig, ProtocolVersion, + ReadResourceRequestParams, }; use rmcp::transport::{IntoTransport, Transport}; use rmcp::{RoleClient, ServiceExt}; @@ -155,7 +156,7 @@ mod tests { } struct RmcpWireFixture { - client: rmcp::service::RunningService, + client: rmcp::service::RunningService, server: Arc, client_messages: Arc>>, server_messages: Arc>>, @@ -165,7 +166,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; @@ -191,7 +192,10 @@ mod tests { sent: Arc::clone(&client_messages), received: Arc::clone(&server_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, @@ -361,6 +365,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 [ ( @@ -397,18 +407,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"], @@ -607,11 +622,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; + } + /// The session lifts 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 8f64d6e339..64eab3e931 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();