Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 13 additions & 8 deletions crates/tracedecay-global-db/src/observation/schema.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -246,9 +249,11 @@ const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str =
pub async fn ensure_observation_schema(
conn: &(impl Executor + Sync),
) -> tracedecay_domain::errors::Result<Option<crate::registered::RefusedAuthorityV1>> {
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,
Expand Down
5 changes: 1 addition & 4 deletions crates/tracedecay-global-db/src/schema_stages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions crates/tracedecay-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"] }
Expand Down
9 changes: 6 additions & 3 deletions crates/tracedecay-mcp/src/server/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions crates/tracedecay-mcp/src/server/rmcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 27 additions & 7 deletions crates/tracedecay-mcp/src/server/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -191,11 +192,30 @@ impl<C: McpConnectionContext> 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);
Expand Down
121 changes: 114 additions & 7 deletions crates/tracedecay-mcp/tests/server_rmcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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::<RoleClient, _, _>::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::<PaginatedRequestParams>)
.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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -398,18 +448,75 @@ 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"]
.as_str()
.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}"
);
}

Expand Down
4 changes: 2 additions & 2 deletions crates/tracedecay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading