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
110 changes: 99 additions & 11 deletions Cargo.lock

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

15 changes: 14 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,20 @@ tokio-util = "0.7"
notify = "6.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
rmcp = { version = "2", features = ["server", "transport-io", "macros", "elicitation", "schemars"] }
# rmcp 3.x = MCP 2026-07-28 spec support (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md,
# Phase 1). Live-verified in a scratch spike (rmcp 3.1.0 + axum 0.8 + schemars 1 + the
# `transport-streamable-http-server` feature): `cargo tree -d` shows exactly one `rmcp` and one
# `axum` node (no OTEL-style split-core repeat of Dependabot PR #46). `transport-streamable-http-server`
# lives in crates/calm-server/Cargo.toml's `http` feature, same as before.
#
# `request-state` (Phase 2, added 2026-08-04): gates `rmcp::model::RequestStateCodec`, used to
# HMAC-seal the SEP-2322 MRTR `requestState` for the hub-edit human-veto gate
# (crates/calm-server/src/tools/edit.rs). Pulls in `hmac 0.13`/`sha2 0.11` as NEW transitive deps,
# distinct majors from this workspace's own `hmac 0.12`/`sha2 0.10` pins below -- unlike the
# OTEL two-cores bug this isn't a problem: `RequestStateCodec` is self-contained (key bytes in,
# sealed string out), CALM's code never constructs an `Hmac`/`Sha2` value itself, and neither
# crate carries global mutable state across versions the way `opentelemetry`'s SDK singleton does.
rmcp = { version = "3", features = ["server", "transport-io", "macros", "elicitation", "schemars", "request-state"] }
schemars = "1"
clap = { version = "4", features = ["derive"] }
rayon = "1"
Expand Down
58 changes: 53 additions & 5 deletions crates/calm-cli/tests/daemon_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,13 @@ fn wait_for(timeout: Duration, cond: impl Fn() -> bool) -> bool {
false
}

fn send_initialize_and_capture(calm_dir_project: &Path) -> std::process::Output {
let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"it","version":"0"}}}
"#;
fn send_initialize_and_capture(
calm_dir_project: &Path,
protocol_version: &str,
) -> std::process::Output {
let request = format!(
"{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{{\"protocolVersion\":\"{protocol_version}\",\"capabilities\":{{}},\"clientInfo\":{{\"name\":\"it\",\"version\":\"0\"}}}}}}\n"
);
let mut child = Command::new(calm_bin())
.arg("connect")
.arg("--project-root")
Expand All @@ -72,7 +76,7 @@ fn send_initialize_and_capture(calm_dir_project: &Path) -> std::process::Output

let stdout = child.stdout.take().expect("piped stdout");
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(request).unwrap();
stdin.write_all(request.as_bytes()).unwrap();

// Wait for the real response (echoes back "id":1) before closing stdin
// — see `StdoutWatcher`'s doc comment. Closing stdin immediately after
Expand Down Expand Up @@ -146,7 +150,7 @@ fn daemon_survives_forwarders_process_group_sigterm() {
let _ = connect.wait();
std::thread::sleep(Duration::from_millis(300));

let output = send_initialize_and_capture(project.path());
let output = send_initialize_and_capture(project.path(), "2024-11-05");
assert!(
output.status.success(),
"a follow-up calm connect must still succeed against the surviving daemon: {}",
Expand All @@ -172,6 +176,50 @@ fn daemon_survives_forwarders_process_group_sigterm() {
}
}

/// MCP 2026-07-28 upgrade (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-
/// plan.md). Phase 1 capped `supported_protocol_versions()` below
/// `2026-07-28` because negotiating it forces Streamable-HTTP statelessness
/// (SEP-2567) regardless of session-manager setup, and the hub-edit
/// human-veto gate had no mechanism that survived that at the time. Phase 2
/// gave the gate an MRTR path (`hub_mrtr_ask`/`hub_mrtr_decide`,
/// tools/edit.rs) whose approve/decline decision is self-contained in a
/// sealed `requestState` rather than per-connection state, closing that gap
/// — so the cap was lifted and a peer offering `2026-07-28` now gets it
/// back, not a downgrade.
#[test]
fn initialize_requesting_2026_07_28_is_now_negotiated_after_phase_2() {
let project = fresh_project();

let output = send_initialize_and_capture(project.path(), "2026-07-28");
assert!(
output.status.success(),
"a client offering 2026-07-28 must get a successful initialize: {}",
String::from_utf8_lossy(&output.stderr)
);
let response = String::from_utf8_lossy(&output.stdout);
assert!(
response.contains("\"protocolVersion\":\"2026-07-28\""),
"expected the server to negotiate 2026-07-28 now that the hub-edit \
gate has an MRTR path that survives statelessness: {response}"
);
}

/// A pre-2026-07-28 client (this exact string is what every other test in
/// this file hardcodes) must still negotiate cleanly — the cap lift must
/// not have disturbed the floor of `ProtocolVersion::SUPPORTED`.
#[test]
fn initialize_requesting_2024_11_05_still_negotiates_that_exact_version() {
let project = fresh_project();

let output = send_initialize_and_capture(project.path(), "2024-11-05");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let response = String::from_utf8_lossy(&output.stdout);
assert!(
response.contains("\"protocolVersion\":\"2024-11-05\""),
"response: {response}"
);
}

/// Reads `stdout` incrementally on a background thread and polls the
/// accumulated bytes until they contain `needle` or `timeout` elapses —
/// synchronizes on the daemon's actual response instead of guessing how
Expand Down
Loading
Loading