diff --git a/Cargo.lock b/Cargo.lock index f257bd38..b06e8cd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "bit-set" version = "0.8.0" @@ -281,6 +287,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.3" @@ -337,7 +352,7 @@ dependencies = [ "anyhow", "fs4", "globset", - "hmac", + "hmac 0.12.1", "ignore", "lsp-types", "model2vec-rs", @@ -350,7 +365,7 @@ dependencies = [ "scip", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlparser", "stack-graphs", "tempfile", @@ -500,6 +515,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -534,6 +555,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "controlled-option" version = "0.4.1" @@ -637,6 +664,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "daachorse" version = "1.0.1" @@ -803,11 +848,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1332,7 +1389,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -1380,6 +1446,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2686,15 +2761,16 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.0", "bytes", "chrono", "futures", + "hmac 0.13.0", "http", "http-body", "http-body-util", @@ -2705,6 +2781,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2 0.11.0", "sse-stream", "thiserror 2.0.18", "tokio", @@ -2718,9 +2795,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "2.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3050,7 +3127,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9c9182c3..f98c728f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/calm-cli/tests/daemon_integration.rs b/crates/calm-cli/tests/daemon_integration.rs index bcea7c80..e4ad8ca7 100644 --- a/crates/calm-cli/tests/daemon_integration.rs +++ b/crates/calm-cli/tests/daemon_integration.rs @@ -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") @@ -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 @@ -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: {}", @@ -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 diff --git a/crates/calm-server/src/tools.rs b/crates/calm-server/src/tools.rs index 67e760b0..c8b12aff 100644 --- a/crates/calm-server/src/tools.rs +++ b/crates/calm-server/src/tools.rs @@ -646,6 +646,34 @@ impl rmcp::ServerHandler for CalmServer { ) } + // rmcp 3.x defaults `supported_protocol_versions()` to every version the + // SDK knows, including `2026-07-28`. Per SEP-2567 (see rmcp's own + // `StreamableHttpServerConfig::legacy_session_mode` doc comment), a peer + // that negotiates `2026-07-28` is *always* served statelessly over + // Streamable-HTTP regardless of `legacy_session_mode`. + // + // Phase 1 of the MCP 2026-07-28 upgrade + // (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md) capped this list + // below `2026-07-28`, because statelessness would have silently dropped + // the hub-edit human-veto gate's declined-answer cache and left it with + // no working mechanism at all (the legacy `elicit_with_timeout` needs a + // live back-channel a stateless connection doesn't have). Phase 2 closed + // that gap: `elicit_setup` (tools/edit.rs) now offers `ElicitMechanism:: + // Mrtr` to any peer negotiating `2026-07-28`+, and `hub_mrtr_ask`/ + // `hub_mrtr_decide` make the approve/decline decision from a self- + // contained, HMAC-sealed `requestState` (`RequestStateCodec`) plus the + // client-echoed `inputResponses` — it does not read or write any + // per-connection state, so it is correct regardless of whether the + // request that asks and the request that answers land on the same + // `CalmServer` instance. `set_toolset` narrowing and the declined-answer + // cache remain per-connection conveniences (a dedup optimization, not a + // safety guarantee) that a genuinely stateless deployment may not + // preserve across requests — tracked as Phase 4 (stateless HTTP) + // territory, not a blocker for allowing negotiation here. + fn supported_protocol_versions(&self) -> std::borrow::Cow<'static, [rmcp::model::ProtocolVersion]> { + std::borrow::Cow::Borrowed(rmcp::model::ProtocolVersion::KNOWN_VERSIONS) + } + // Hand-written instead of relying on `#[tool_router]`'s bare merged // router so `list_tools`/`call_tool` go through `self.tool_router`, // which already has every tool outside `self.preset` disabled (see @@ -657,23 +685,25 @@ impl rmcp::ServerHandler for CalmServer { _context: rmcp::service::RequestContext, ) -> Result { let visible = self.current_visible_tool_names(); - Ok(rmcp::model::ListToolsResult { - next_cursor: None, - tools: self - .tool_router + // `with_all_items` leaves result_type/ttl_ms/cache_scope (SEP-2549) at + // their no-cache-hint defaults -- Phase 3 of the MCP 2026-07-28 upgrade + // (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md) is where this + // toolset already emits `tool_list_changed` on every `set_toolset` + // narrowing, so that's the natural cache-bust signal to wire up then. + Ok(rmcp::model::ListToolsResult::with_all_items( + self.tool_router .list_all() .into_iter() .filter(|t| visible.contains(t.name.as_ref())) .collect(), - meta: None, - }) + )) } async fn call_tool( &self, request: rmcp::model::CallToolRequestParams, - context: rmcp::service::RequestContext, - ) -> Result { + mut context: rmcp::service::RequestContext, + ) -> Result { // Preset scoping already lives in `self.tool_router` (built by // `tool_router_for_preset` at construction) — a disabled tool is // rejected by `ToolRouter::call` itself, so no separate @@ -724,7 +754,8 @@ impl rmcp::ServerHandler for CalmServer { ); return Ok(rmcp::model::CallToolResult::error(vec![ rmcp::model::ContentBlock::text(self.orientation_required_message()), - ])); + ]) + .into()); } // Runtime toolset gate (Phase 1). Enforced here, not just in @@ -747,13 +778,40 @@ impl rmcp::ServerHandler for CalmServer { "tool {tool_name:?} is not in this session's active toolset; \ call set_toolset to widen it" )), - ])); + ]) + .into()); + } + + // SEP-2322 MRTR continuation (docs/plans/2026-08-04-mcp-2026-07-28- + // upgrade-plan.md Phase 2): `ToolCallContext::new` below discards + // `input_responses`/`request_state` from `request` (rmcp 3.x has no + // extractor for them), so this is the only place that can forward a + // retry's answer to the tool method that asked -- via `extensions`, + // rmcp's own typed pass-through, tool-agnostic (no per-tool branch + // needed here; `edit_lines_tool`/`edit_symbol_tool` check for it). + if let (Some(input_responses), Some(request_state)) = + (request.input_responses.clone(), request.request_state.clone()) + { + context.extensions.insert(edit::MrtrContinuation { + input_responses, + request_state, + }); } - let tool_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); let mut result = self.tool_router.call(tool_context).instrument(span).await; - if let Ok(r) = &mut result { + // rmcp 3.x: `ToolRouter::call` returns `CallToolResponse`, an enum + // covering the ordinary completed result plus the MRTR + // (`InputRequired`) and Tasks (`Task`) variants (SEP-2322/SEP-2663) — + // neither of which any tool on this server emits yet, so this always + // matches `Complete` in practice today. Written as a real match (not + // an `if let ... else` that silently drops the other arms) so the + // orientation-injection / pending-diff-impact-reminder text keeps + // landing correctly the day a tool starts returning `InputRequired` + // (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md Phase 2) — + // there is no `content` to append text to on that variant, so it's a + // deliberate no-op rather than a bug. + if let Ok(rmcp::model::CallToolResponse::Complete(r)) = &mut result { if is_adjacent { self.oriented .store(true, std::sync::atomic::Ordering::SeqCst); @@ -770,6 +828,11 @@ impl rmcp::ServerHandler for CalmServer { { r.content.push(rmcp::model::ContentBlock::text(reminder)); } + } else if let Ok(_) = &result + && is_adjacent + { + self.oriented + .store(true, std::sync::atomic::Ordering::SeqCst); } result } @@ -781,11 +844,9 @@ impl rmcp::ServerHandler for CalmServer { Output = Result, > + Send + '_ { - std::future::ready(Ok(rmcp::model::ListPromptsResult { - next_cursor: None, - prompts: ci_prompts(), - meta: None, - })) + std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items( + ci_prompts(), + ))) } fn get_prompt( @@ -793,7 +854,7 @@ impl rmcp::ServerHandler for CalmServer { request: rmcp::model::GetPromptRequestParams, _context: rmcp::service::RequestContext, ) -> impl std::future::Future< - Output = Result, + Output = Result, > + Send + '_ { let result = @@ -806,7 +867,7 @@ impl rmcp::ServerHandler for CalmServer { .into_iter() .find(|p| p.name == request.name) .and_then(|p| p.description); - Ok(result) + Ok(result.into()) } None => Err(rmcp::model::ErrorData::invalid_params( format!("unknown prompt: {}", request.name), diff --git a/crates/calm-server/src/tools/edit.rs b/crates/calm-server/src/tools/edit.rs index a18da8d5..64486b50 100644 --- a/crates/calm-server/src/tools/edit.rs +++ b/crates/calm-server/src/tools/edit.rs @@ -23,45 +23,70 @@ impl CalmServer { destructive_hint = true, idempotent_hint = false, open_world_hint = false - ) + ), + output_schema = rmcp::handler::server::tool::schema_for_output::>() )] pub(crate) async fn edit_lines_tool( &self, Parameters(p): Parameters, ctx: rmcp::service::RequestContext, - ) -> Json> { - let elicit_timeout = self.elicit_setup(&ctx.peer); - let gate = if elicit_timeout.is_some() { + ) -> HubEditToolResult> { + // SEP-2322 retry: this exact tools/call carries the client's answer + // to a prior `input_required` result (dispatcher-stashed — see + // `CalmServer::call_tool`'s use of `MrtrContinuation`). Decide from + // the sealed state instead of re-asking; `p`/`ctx` are otherwise the + // same fresh extraction as any other call since the client retries + // the full original request, arguments included. + if let Some(continuation) = ctx.extensions.get::() { + let fingerprint = fingerprint_edit_lines(&p); + return HubEditToolResult::Done(Json( + match self.hub_mrtr_decide("edit_lines", &p.path, &fingerprint, continuation) { + Ok(()) => self.edit_lines_flow(&p, ElicitGate::Approved, &mut None), + Err(detail) => ToolOutcome::error(detail), + }, + )); + } + + let elicit_mechanism = self.elicit_setup(&ctx); + let gate = if elicit_mechanism.is_some() { ElicitGate::Ask } else { ElicitGate::Off }; let mut ask: Option = None; let first = self.edit_lines_flow(&p, gate, &mut ask); - let (Some(timeout), Some(ask_ctx)) = (elicit_timeout, ask) else { - return Json(first); + let (Some(mechanism), Some(ask_ctx)) = (elicit_mechanism, ask) else { + return HubEditToolResult::Done(Json(first)); }; // `first` has fully returned above — neither the in-process // edit_lock nor the cross-process lock (both scoped inside - // edit_lines_impl_gated) is held across this await (audit FM1). + // edit_lines_impl_gated) is held across the await below (audit FM1). let fingerprint = fingerprint_edit_lines(&p); - Json( - match self - .hub_elicit_roundtrip( - &ctx.peer, - "edit_lines", - &p.path, - &fingerprint, - &ask_ctx, - p.reason.as_deref(), - timeout, - ) - .await - { - Ok(()) => self.edit_lines_flow(&p, ElicitGate::Approved, &mut None), - Err(detail) => ToolOutcome::error(detail), - }, - ) + match mechanism { + ElicitMechanism::Mrtr { timeout } => { + match self.hub_mrtr_ask("edit_lines", &p.path, &fingerprint, &ask_ctx, p.reason.as_deref(), timeout) { + Ok(result) => HubEditToolResult::NeedsApproval(result), + Err(detail) => HubEditToolResult::Done(Json(ToolOutcome::error(detail))), + } + } + ElicitMechanism::LegacyRoundTrip { timeout } => HubEditToolResult::Done(Json( + match self + .hub_elicit_roundtrip( + &ctx.peer, + "edit_lines", + &p.path, + &fingerprint, + &ask_ctx, + p.reason.as_deref(), + timeout, + ) + .await + { + Ok(()) => self.edit_lines_flow(&p, ElicitGate::Approved, &mut None), + Err(detail) => ToolOutcome::error(detail), + }, + )), + } } /// Legacy sync surface — same behavior as `edit_lines_tool` with the @@ -187,45 +212,67 @@ impl CalmServer { destructive_hint = true, idempotent_hint = false, open_world_hint = false - ) + ), + output_schema = rmcp::handler::server::tool::schema_for_output::>() )] pub(crate) async fn edit_symbol_tool( &self, Parameters(p): Parameters, ctx: rmcp::service::RequestContext, - ) -> Json> { - let elicit_timeout = self.elicit_setup(&ctx.peer); - let gate = if elicit_timeout.is_some() { + ) -> HubEditToolResult> { + // SEP-2322 retry — see edit_lines_tool's identical branch for the + // full rationale. + if let Some(continuation) = ctx.extensions.get::() { + let fingerprint = fingerprint_edit_symbol(&p); + let cache_key_path = p.path.clone().unwrap_or_else(|| p.symbol.clone()); + return HubEditToolResult::Done(Json( + match self.hub_mrtr_decide("edit_symbol", &cache_key_path, &fingerprint, continuation) { + Ok(()) => self.edit_symbol_flow(&p, ElicitGate::Approved, &mut None), + Err(detail) => ResolvedOutcome::error(detail), + }, + )); + } + + let elicit_mechanism = self.elicit_setup(&ctx); + let gate = if elicit_mechanism.is_some() { ElicitGate::Ask } else { ElicitGate::Off }; let mut ask: Option = None; let first = self.edit_symbol_flow(&p, gate, &mut ask); - let (Some(timeout), Some(ask_ctx)) = (elicit_timeout, ask) else { - return Json(first); + let (Some(mechanism), Some(ask_ctx)) = (elicit_mechanism, ask) else { + return HubEditToolResult::Done(Json(first)); }; // `first` has fully returned above — no edit/DB lock is held across - // this await (all scoped inside edit_lines_impl_gated); audit FM1. + // the await below (all scoped inside edit_lines_impl_gated); audit FM1. let fingerprint = fingerprint_edit_symbol(&p); let cache_key_path = p.path.clone().unwrap_or_else(|| p.symbol.clone()); - Json( - match self - .hub_elicit_roundtrip( - &ctx.peer, - "edit_symbol", - &cache_key_path, - &fingerprint, - &ask_ctx, - p.reason.as_deref(), - timeout, - ) - .await - { - Ok(()) => self.edit_symbol_flow(&p, ElicitGate::Approved, &mut None), - Err(detail) => ResolvedOutcome::error(detail), - }, - ) + match mechanism { + ElicitMechanism::Mrtr { timeout } => { + match self.hub_mrtr_ask("edit_symbol", &cache_key_path, &fingerprint, &ask_ctx, p.reason.as_deref(), timeout) { + Ok(result) => HubEditToolResult::NeedsApproval(result), + Err(detail) => HubEditToolResult::Done(Json(ResolvedOutcome::error(detail))), + } + } + ElicitMechanism::LegacyRoundTrip { timeout } => HubEditToolResult::Done(Json( + match self + .hub_elicit_roundtrip( + &ctx.peer, + "edit_symbol", + &cache_key_path, + &fingerprint, + &ask_ctx, + p.reason.as_deref(), + timeout, + ) + .await + { + Ok(()) => self.edit_symbol_flow(&p, ElicitGate::Approved, &mut None), + Err(detail) => ResolvedOutcome::error(detail), + }, + )), + } } /// Legacy sync surface — same behavior as `edit_symbol_tool` with the @@ -1853,25 +1900,109 @@ pub(crate) struct HubEditApproval { } rmcp::elicit_safe!(HubEditApproval); +/// Which transport mechanism carries the hub-edit human-veto question to +/// the client and its answer back (docs/plans/2026-08-04-mcp-2026-07-28- +/// upgrade-plan.md Phase 2). Both variants converge on the exact same +/// fail-closed decision — only an explicit `approve: true` lets the write +/// proceed — via `map_elicit_outcome` (legacy) / `decide_mrtr_answer` (MRTR). +#[derive(Clone, Copy)] +enum ElicitMechanism { + /// Pre-2026-07-28: server-initiated `elicitation/create` over a live + /// back-channel (`peer.elicit_with_timeout`) — requires the client to + /// have declared Form-mode elicitation at `initialize`. Byte-identical + /// to CALM's original (pre-MRTR) behavior. + LegacyRoundTrip { timeout: std::time::Duration }, + /// SEP-2322: the tool call returns `resultType: "input_required"` + /// instead of writing; the client retries the SAME `tools/call` with + /// `inputResponses` (and the echoed `requestState`) once a human + /// answers. Works over a genuinely stateless connection — no back- + /// channel needed — which is exactly what a peer negotiating + /// `2026-07-28` may be served over (SEP-2567). `timeout` becomes the + /// sealed state's TTL. + Mrtr { timeout: std::time::Duration }, +} + +/// Sealed payload bound into SEP-2322 `requestState` for a pending hub-edit +/// approval — verified on retry via `RequestStateCodec` (HMAC-SHA256) so a +/// client cannot forge an approval or replay one against a since-modified +/// edit (the retry's freshly-recomputed fingerprint must match this exactly). +#[derive(serde::Serialize, Deserialize)] +struct HubEditStateSeal { + tool: String, + cache_path: String, + fingerprint: String, +} + +/// Stashed by the dispatcher (`CalmServer::call_tool` in tools.rs) into +/// `RequestContext::extensions` when an incoming `tools/call` carries SEP- +/// 2322 continuation fields — i.e. it's a retry answering a prior +/// `input_required` result, not a fresh call. `ToolCallContext::new` +/// (rmcp 3.x) discards `input_responses`/`request_state` from the raw +/// request before an individual `#[tool]` method ever sees it, so the +/// dispatcher is the only place that can forward them — `extensions` is +/// rmcp's own typed pass-through for exactly this. +#[derive(Clone)] +pub(crate) struct MrtrContinuation { + pub(crate) input_responses: rmcp::model::InputResponses, + pub(crate) request_state: String, +} + +/// Either a tool's normal completed result, or a SEP-2322 `input_required` +/// intermediate result for the hub-edit gate. `#[tool]`'s macro can only +/// auto-derive `output_schema` from a literal `Json` (or `Result, +/// _>`) return type, so `edit_lines_tool`/`edit_symbol_tool` each carry an +/// explicit `output_schema = schema_for_output::()` attribute reproducing +/// exactly what it would have derived from their original `Json` return +/// type — verified against a `#[tool(output_schema = ...)]` example in +/// rmcp's own test suite (tests/test_json_schema_detection.rs). +enum HubEditToolResult { + Done(Json), + NeedsApproval(rmcp::model::InputRequiredResult), +} + +impl rmcp::handler::server::tool::IntoCallToolResult + for HubEditToolResult +{ + fn into_call_tool_result(self) -> Result { + match self { + Self::Done(json) => json.into_call_tool_result(), + Self::NeedsApproval(result) => result.into_call_tool_result(), + } + } +} + impl CalmServer { - /// `Some(timeout)` when the human-veto flow is active for this - /// connection: `[edit] elicit_hub_confirm` opted in AND the client - /// declared form-mode elicitation (MCP 2025-06-18 requires clients to - /// declare it at initialize). `None` = `ElicitGate::Off`, byte-identical - /// legacy behavior — by construction the veto can only ADD a refusal on - /// top of the machine gate, never remove one (spec Option A). - fn elicit_setup(&self, peer: &rmcp::Peer) -> Option { + /// `Some(mechanism)` when the human-veto flow is active for this call: + /// `[edit] elicit_hub_confirm` opted in AND (a) the peer negotiated + /// `2026-07-28`+ (MRTR — works statelessly, no capability declaration + /// needed), or (b) the client declared form-mode elicitation at + /// `initialize` (legacy — MCP 2025-06-18 requires declaring it up + /// front). `None` = `ElicitGate::Off`, byte-identical legacy behavior — + /// by construction the veto can only ADD a refusal on top of the + /// machine gate, never remove one (spec Option A). + fn elicit_setup( + &self, + ctx: &rmcp::service::RequestContext, + ) -> Option { let cfg = self.config().edit; if !cfg.elicit_hub_confirm { return None; } - if !peer + let timeout = std::time::Duration::from_secs(cfg.elicit_timeout_secs); + let mrtr_capable = ctx.protocol_version().is_some_and(|v| { + v.as_str() >= rmcp::model::ProtocolVersion::V_2026_07_28.as_str() + }); + if mrtr_capable { + return Some(ElicitMechanism::Mrtr { timeout }); + } + if ctx + .peer .supported_elicitation_modes() .contains(&rmcp::service::ElicitationMode::Form) { - return None; + return Some(ElicitMechanism::LegacyRoundTrip { timeout }); } - Some(std::time::Duration::from_secs(cfg.elicit_timeout_secs)) + None } /// One human-veto round-trip: declined-cache short-circuit, sanitized @@ -1922,6 +2053,155 @@ impl CalmServer { } mapped } + + /// SEP-2322 "ask" side: same declined-cache short-circuit and audit + /// logging as `hub_elicit_roundtrip`, but builds a sealed + /// `InputRequiredResult` instead of awaiting a live round-trip -- the + /// client retries this exact `tools/call` with the answer, and + /// `hub_mrtr_decide` verifies it. `Err` here means "refuse immediately, + /// do not even ask" (mirrors `hub_elicit_roundtrip`'s own declined-cache + /// short-circuit) -- the caller returns it as an ordinary completed + /// error result, never as `NeedsApproval`. + fn hub_mrtr_ask( + &self, + tool: &str, + cache_path: &str, + fingerprint: &str, + ask: &HubAskContext, + reason: Option<&str>, + timeout: std::time::Duration, + ) -> Result { + if self.elicit_declined_contains(cache_path, fingerprint) { + return Err(error_detail( + "USER_DECLINED", + "a human already declined this exact edit this session — do not \ + retry it; surface their veto and let them decide the next step", + false, + )); + } + let message = build_hub_elicit_message(tool, cache_path, ask, reason); + let seal = HubEditStateSeal { + tool: tool.to_string(), + cache_path: cache_path.to_string(), + fingerprint: fingerprint.to_string(), + }; + let key = calm_core::memory::load_or_create_mac_key(&self.project_root).map_err(|e| { + error_detail( + "ELICITATION_FAILED", + &format!("could not prepare the approval request: {e}"), + false, + ) + })?; + let codec = rmcp::model::RequestStateCodec::new(key.to_vec()); + let request_state = codec + .seal_json_with(&seal, &rmcp::model::SealOptions::new().ttl(timeout)) + .map_err(|e| { + error_detail( + "ELICITATION_FAILED", + &format!("could not prepare the approval request: {e}"), + false, + ) + })?; + let schema = rmcp::model::ElicitationSchema::from_type::().map_err(|e| { + error_detail( + "ELICITATION_FAILED", + &format!("could not build the approval schema: {e}"), + false, + ) + })?; + let mut input_requests = rmcp::model::InputRequests::new(); + input_requests.insert( + "approval".to_string(), + rmcp::model::InputRequest::Elicitation(rmcp::model::ElicitRequest::new( + rmcp::model::ElicitRequestParams::FormElicitationParams { + meta: None, + message, + requested_schema: schema, + }, + )), + ); + tracing::info!( + target: crate::telemetry::AUDIT_TARGET, + session_id = self.session_id, + decision = "elicit_asked_mrtr", + tool, + path = cache_path, + ); + Ok(rmcp::model::InputRequiredResult::new( + Some(input_requests), + Some(request_state), + )) + } + + /// SEP-2322 "decide" side: opens the sealed `request_state`, verifying + /// it matches this exact retried call (tool/path/fingerprint -- a + /// mismatch means the edit changed since it was asked about, or the + /// state was forged/replayed), extracts the client's answer from + /// `input_responses`, and maps it through `decide_mrtr_answer` -- + /// exactly the same fail-closed philosophy as `map_elicit_outcome` + /// (only an explicit `approve: true` on a verified state ever succeeds). + fn hub_mrtr_decide( + &self, + tool: &str, + cache_path: &str, + fingerprint: &str, + continuation: &MrtrContinuation, + ) -> Result<(), ErrorDetail> { + let key = calm_core::memory::load_or_create_mac_key(&self.project_root).map_err(|e| { + error_detail( + "ELICITATION_FAILED", + &format!("could not verify the approval: {e}"), + false, + ) + })?; + let codec = rmcp::model::RequestStateCodec::new(key.to_vec()); + let seal: HubEditStateSeal = + codec + .open_json(&continuation.request_state) + .map_err(|_| { + error_detail( + "ELICITATION_FAILED", + "the approval request has expired or its state could not be \ + verified — nothing was written (fail-closed); retry the edit \ + to ask again", + false, + ) + })?; + if seal.tool != tool || seal.cache_path != cache_path || seal.fingerprint != fingerprint { + return Err(error_detail( + "ELICITATION_FAILED", + "the approval does not match this edit — it changed since it was \ + asked about — nothing was written (fail-closed); retry the edit to \ + ask again", + false, + )); + } + let answer = continuation + .input_responses + .get("approval") + .map(|v| serde_json::from_value::(v.clone())) + .transpose() + .map_err(|_| { + error_detail( + "ELICITATION_FAILED", + "the approval answer was malformed — nothing was written \ + (fail-closed)", + false, + ) + })?; + let (verdict, mapped) = decide_mrtr_answer(answer); + tracing::info!( + target: crate::telemetry::AUDIT_TARGET, + session_id = self.session_id, + decision = verdict, + tool, + path = cache_path, + ); + if verdict == "elicit_declined" { + self.elicit_declined_insert(cache_path, fingerprint); + } + mapped + } } /// Pure decision-table mapping — unit-testable without a live peer. Returns @@ -1970,6 +2250,32 @@ fn map_elicit_outcome( } } +/// SEP-2322 sibling of `map_elicit_outcome` — same fail-closed philosophy +/// (only `approve: true` succeeds) and the same verdict labels, but for a +/// verified-but-possibly-absent MRTR answer rather than a live round-trip's +/// richer error taxonomy (no server-side timeout concept applies here: if +/// the client never retries, nothing happens — there is no pending await to +/// time out). Pure decision-table mapping — unit-testable without a live +/// peer, exactly like `map_elicit_outcome`. `hub_mrtr_decide` handles the +/// separate MRTR-specific failure modes (expired/tampered `request_state`, +/// malformed answer JSON) before ever reaching this function. +fn decide_mrtr_answer( + answer: Option, +) -> (&'static str, Result<(), ErrorDetail>) { + match answer { + Some(HubEditApproval { approve: true }) => ("elicit_approved", Ok(())), + Some(HubEditApproval { approve: false }) | None => ( + "elicit_declined", + Err(error_detail( + "USER_DECLINED", + "the human reviewing this session refused this hub edit — do not \ + retry; surface their veto and let them decide the next step", + false, + )), + ), + } +} + /// Builds the human-facing question. `reason` is agent-authored text about /// to cross into a human approval UI — run through the same redaction layer /// as source output and hard-capped, per audit FM3 (the reason field must @@ -3061,4 +3367,172 @@ mod elicit_tests { assert!(msg.contains("hub_kind=degree")); assert!(msg.contains("risk=high")); } + + // --- SEP-2322 MRTR (docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md + // Phase 2). `decide_mrtr_answer` is `map_elicit_outcome`'s sibling for + // the MRTR answer shape — same fail-closed table, mirrored 1:1. --- + + #[test] + fn decide_mrtr_answer_approve_true_is_the_only_ok() { + let (verdict, mapped) = decide_mrtr_answer(Some(HubEditApproval { approve: true })); + assert_eq!(verdict, "elicit_approved"); + assert!(mapped.is_ok()); + } + + #[test] + fn decide_mrtr_answer_approve_false_and_missing_answer_decline() { + for answer in [Some(HubEditApproval { approve: false }), None] { + let (verdict, mapped) = decide_mrtr_answer(answer); + assert_eq!(verdict, "elicit_declined"); + assert_eq!(mapped.unwrap_err().code, "USER_DECLINED"); + } + } + + fn mrtr_test_server(name: &str) -> (std::path::PathBuf, CalmServer) { + let dir = std::env::temp_dir().join(format!("ci_mrtr_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let server = CalmServer::new(dir.clone(), dir.join("index.db")).unwrap(); + (dir, server) + } + + fn ask_context() -> HubAskContext { + HubAskContext { + why: "a hub symbol (is_hub=true)".into(), + risk: Some("high".into()), + hub_kind: Some("degree".into()), + touched: vec![("a.py::helper".into(), 12)], + } + } + + #[test] + fn hub_mrtr_ask_then_decide_approved_round_trip() { + let (dir, server) = mrtr_test_server("approved_round_trip"); + let ask = ask_context(); + let result = server + .hub_mrtr_ask( + "edit_lines", + "a.py", + "fp-1", + &ask, + None, + std::time::Duration::from_secs(60), + ) + .map_err(|e| e.code) + .expect("first ask must not be short-circuited"); + let request_state = result.request_state.expect("must carry sealed state"); + let mut input_responses = rmcp::model::InputResponses::new(); + input_responses.insert( + "approval".to_string(), + serde_json::json!({ "approve": true }), + ); + let continuation = MrtrContinuation { + input_responses, + request_state, + }; + let decision = server.hub_mrtr_decide("edit_lines", "a.py", "fp-1", &continuation); + assert!(decision.map_err(|e| e.code).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hub_mrtr_decide_rejects_when_edit_changed_since_the_ask() { + // Defense against replaying a stale approval against a + // since-modified edit: the retry's freshly-recomputed fingerprint + // must match what was sealed, or the decision fails closed. + let (dir, server) = mrtr_test_server("tampered_fingerprint"); + let ask = ask_context(); + let result = server + .hub_mrtr_ask( + "edit_lines", + "a.py", + "fp-original", + &ask, + None, + std::time::Duration::from_secs(60), + ) + .map_err(|e| e.code) + .unwrap(); + let mut input_responses = rmcp::model::InputResponses::new(); + input_responses.insert( + "approval".to_string(), + serde_json::json!({ "approve": true }), + ); + let continuation = MrtrContinuation { + input_responses, + request_state: result.request_state.unwrap(), + }; + // Retry claims a DIFFERENT fingerprint than what was sealed. + let decision = server.hub_mrtr_decide("edit_lines", "a.py", "fp-changed", &continuation); + let err = decision.unwrap_err(); + assert_eq!(err.code, "ELICITATION_FAILED"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hub_mrtr_decide_rejects_a_forged_request_state() { + let (dir, server) = mrtr_test_server("forged_state"); + let continuation = MrtrContinuation { + input_responses: { + let mut m = rmcp::model::InputResponses::new(); + m.insert("approval".to_string(), serde_json::json!({ "approve": true })); + m + }, + request_state: "not-a-real-sealed-value".to_string(), + }; + let decision = server.hub_mrtr_decide("edit_lines", "a.py", "fp-1", &continuation); + assert_eq!(decision.unwrap_err().code, "ELICITATION_FAILED"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hub_mrtr_decide_declines_on_missing_or_false_answer() { + let (dir, server) = mrtr_test_server("declined_answer"); + let ask = ask_context(); + for (label, responses) in [ + ("missing", rmcp::model::InputResponses::new()), + ("false", { + let mut m = rmcp::model::InputResponses::new(); + m.insert("approval".to_string(), serde_json::json!({ "approve": false })); + m + }), + ] { + let result = server + .hub_mrtr_ask( + "edit_lines", + "a.py", + &format!("fp-{label}"), + &ask, + None, + std::time::Duration::from_secs(60), + ) + .map_err(|e| e.code) + .unwrap(); + let continuation = MrtrContinuation { + input_responses: responses, + request_state: result.request_state.unwrap(), + }; + let decision = + server.hub_mrtr_decide("edit_lines", "a.py", &format!("fp-{label}"), &continuation); + assert_eq!(decision.unwrap_err().code, "USER_DECLINED", "case {label}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hub_mrtr_ask_short_circuits_when_already_declined_this_session() { + let (dir, server) = mrtr_test_server("already_declined_short_circuit"); + server.elicit_declined_insert("a.py", "fp-1"); + let ask = ask_context(); + let result = server.hub_mrtr_ask( + "edit_lines", + "a.py", + "fp-1", + &ask, + None, + std::time::Duration::from_secs(60), + ); + assert_eq!(result.unwrap_err().code, "USER_DECLINED"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md b/docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md new file mode 100644 index 00000000..ba527011 --- /dev/null +++ b/docs/plans/2026-08-04-mcp-2026-07-28-upgrade-plan.md @@ -0,0 +1,238 @@ +--- +title: "Nâng cấp CALM theo MCP spec 2026-07-28 — nghiên cứu sâu + kế hoạch xếp theo giá trị" +date: 2026-08-04 +status: nghiên cứu + thiết kế (chưa code) — đề xuất lộ trình 4 phase, xếp ưu tiên theo giá trị thực dụng +scope: > + Đánh giá từng thay đổi kiến trúc của spec MCP 2026-07-28 (theo từng SEP) đối chiếu với deployment + thật của CALM (stdio + shared daemon + opt-in HTTP, single-node, single SQLite, SINGLE_WRITER), + rồi xếp hạng và lên lộ trình adoption. KHÔNG adopt tất cả — chỉ những gì trả về giá trị thật, và + xử lý đúng phần deprecation bắt buộc (elicitation). +inputs: + - Cargo.toml (rmcp = "2" → khoá cứng 2.2.0, loại trừ nhánh 3.x) + - crates/calm-server/src/tools.rs (get_info / capabilities / list_tools / call_tool) + - crates/calm-server/src/tools/edit.rs (hub-edit human-veto qua elicitation) + - crates/calm-server/src/http.rs + crates/calm-cli/src/http.rs (Streamable-HTTP, LocalSessionManager, resolve_http_launch) + - crates/calm-server/src/tools/orient.rs (set_toolset + notify_tool_list_changed — dynamic toolsets) + - crates/calm-server/src/tools/recover.rs (indexing_status — mô hình handle-based polling) + - server.json / npm/calm-mcp/package.json (transport = stdio) +verified_against: > + HEAD b96342b. Đọc trực tiếp qua mcp__calm__source/understand/search trong phiên này. Chuỗi hằng + ProtocolVersion đọc từ source rmcp-2.2.0 đã vendor tại ~/.cargo (LATEST = V_2025_11_25). Ngày phát + hành rmcp 3.x lấy từ crates.io API. Danh sách SEP lấy từ blog.modelcontextprotocol.io/posts/2026-07-28. + CẢNH BÁO: tên API cụ thể của rmcp 3.x (legacy_session_mode, serve_with_lifecycle, InputRequiredResult…) + lấy từ release-notes summary — PHẢI verify lại trực tiếp trên crate trong Phase 0 trước khi dựa vào. +--- + +# Nâng cấp CALM theo MCP 2026-07-28 + +## TL;DR — kết luận & khuyến nghị (đọc phần này là đủ để quyết) + +1. **Hiện trạng: CALM đứng ở thế hệ 2025, chưa chạm 2026-07-28.** SDK nền `rmcp` bị khoá `= "2"` + (Cargo.lock = 2.2.0, `LATEST = V_2025_11_25`). Nhánh triển khai đầy đủ spec mới là `rmcp` **3.x** + (3.0.0 ra 2026-07-28, 3.1.0 ra 2026-07-31) — semver `"2"` loại trừ hoàn toàn. + +2. **KHÔNG nên "adopt tất cả".** Phần lớn 2026-07-28 tối ưu cho triển khai **remote, multi-tenant, + scale ngang sau load balancer** — điều CALM **không phải** (single-node, single SQLite, SINGLE_WRITER, + transport mặc định là stdio). Với deployment thật của CALM, giá trị của stateless-core / header-routing + / OAuth-hardening là **thấp**. + +3. **Có ĐÚNG MỘT hạng mục giá trị cao và có deadline:** di trú **human-veto gate của write-path** + từ **elicitation** (đang bị deprecate, cửa sổ 12 tháng — SEP-2577) sang **Multi Round-Trip Requests / + `input_required`** (SEP-2322). Việc này (a) vá một **lỗ hổng an toàn cross-transport có thật** — gate + tự tắt lặng lẽ trên kết nối stateless, và (b) sống sót qua đợt deprecation, và (c) khớp với triết lý + "explicit model-visible handles" mà CALM **đã** theo ở edit-transaction. → **Đây là mỏ neo của cả + đợt nâng cấp.** + +4. **Lộ trình đề xuất (4 phase):** + - **P0** — spike de-risk (giải quyết resolution rmcp3 + axum0.8 + schemars1; chốt posture HTTP). + - **P1** — bump `rmcp 2→3` **giữ nguyên hành vi** (HTTP để `legacy_session_mode`), CI xanh. Nhận + "free": header routing + đàm phán 2026-07-28 + structured output (vốn đã có). + - **P2** — di trú gate an toàn sang **MRTR** (giá trị cao nhất). + - **P3** — cacheable tool/prompt lists (giá trị trung bình, hiệu quả token). + - **P4 (hoãn)** — stateless HTTP + OAuth/CIMD + tasks extension: **chỉ làm nếu** muốn biến CALM thành + dịch vụ hosted/multi-user. Với single-node hôm nay: giá trị thấp, rủi ro cao. + +5. **Rủi ro nổi bật:** nhánh `rmcp` 3.x mới ~1 tuần tuổi, mà lại nằm trên **write-path an toàn**. Không + vội ghim vào production; stage cẩn thận, giữ mọi fail-closed gate hiện có, chờ 3.1.x/3.2 lắng nếu cần. + +--- + +## 1. Hiện trạng có kiểm chứng (không suy đoán) + +### 1.1 SDK bị khoá dưới ngưỡng triển khai spec mới + +`Cargo.toml`: +```toml +rmcp = { version = "2", features = ["server", "transport-io", "macros", "elicitation", "schemars"] } +``` +→ Cargo.lock khoá **rmcp 2.2.0**. Đọc thẳng source đã vendor: +`ProtocolVersion::LATEST = V_2025_11_25`; hằng `V_2026_07_28` **có tồn tại** nhưng toàn crate 2.2.0 +chỉ dùng nó ở **một** nhánh (chọn mã lỗi `INVALID_PARAMS` chuẩn khi peer tự đàm phán ≥2026-07-28, +SEP-2164). Không có stateless-core / MRTR / header-routing / cache-hints / extensions trong 2.2.0 — +chúng chỉ có từ **rmcp 3.0.0** trở đi. + +| rmcp | ngày | ý nghĩa | +|---|---|---| +| 2.2.0 | ~07/2025 | đang dùng; LATEST=2025-11-25; "biết" 2026-07-28 chỉ ở mức tên | +| 3.0.0 | 2026-07-28 | triển khai đầy đủ 2026-07-28 (đúng ngày spec chốt) | +| 3.1.0 | 2026-07-31 | siết validation stateless metadata, phân loại lỗi auth-required | + +### 1.2 CALM đã "hiện đại" ở chỗ nào (đừng phá) + +- **Structured tool output: ĐÃ CÓ.** Mọi tool trả `Json>` (rmcp structured content), + và có generate `output_schema` (test toolsnap `tools.rs:1114`). → hạng mục output-schema của spec + coi như **xong**, 0 việc. +- **Explicit model-visible handles: ĐÃ CÓ.** Edit-transaction (`verify_change`, + `edit_transaction_status`) + `session_context` + `set_toolset` là đúng pattern mà spec 2026-07-28 + khuyến nghị để thay cho "state ẩn trong transport". Đây là **điểm mạnh**, không phải nợ. +- **Long-running ops qua polling handle: ĐÃ CÓ.** `indexing_status` / `maintenance_status` / + `edit_transaction_status` chính là mô hình poll-based của tasks extension (SEP-2663), nhưng **giàu + domain-field hơn** một task envelope generic. +- **Không dùng thứ bị khai tử:** CALM không dùng Roots/Sampling; "logging" của CALM là `tracing`/OTEL + phía server, **không** phải capability `logging/setLevel` của MCP; HTTP dùng **Streamable-HTTP** (bản + hiện đại), không phải HTTP+SSE legacy. → SEP-2577 gần như không đụng CALM, **trừ elicitation**. + +### 1.3 Điểm căng thẳng kiến trúc thật sự (nơi cần hành động) + +- **Human-veto gate dựa trên elicitation** (`edit.rs`): `elicit_setup` kiểm tra + `peer.supported_elicitation_modes()` chứa `Form`, rồi `hub_elicit_roundtrip` gọi + `peer.elicit_with_timeout::()` (server-initiated, cần back-channel bền). + Hệ quả kép: + - **Deprecation:** elicitation nằm trong SEP-2577, cửa sổ 12 tháng. + - **Lỗ hổng cross-transport:** trên kết nối **stateless** 2026-07-28 (không có back-channel + server→client), client sẽ không khai báo Form elicitation → `elicit_setup` trả `None` → + **gate im lặng chuyển sang Off**. Tức là lớp an toàn quan trọng nhất **tự tắt đúng lúc cần nhất**. + (Trên stdio hôm nay gate vẫn chạy tốt vì stdio là pipe bền — nên đây là rủi ro *tương lai/HTTP*, + không phải bug hôm nay, nhưng là lý do mạnh nhất để di trú.) +- **State ẩn theo-connection:** `session_id`, toolset narrowing (`apply_toolset_inner`), và + **elicit-declined cache** đều sống trong `for_connection()`. Với stdio/daemon (kết nối bền) thì ổn; + nhưng đây chính là loại state mà stateless-core loại bỏ — nên nếu sau này bật stateless HTTP thì phải + đưa chúng thành explicit handle hoặc chấp nhận mất. +- **HTTP transport = stateful:** `serve_http` dựng `StreamableHttpService` với + `LocalSessionManager::default()` (mô hình `Mcp-Session-Id`). Đây là mô hình 2025, ngược hướng + stateless-by-default của 2026-07-28. Auth = **một** static bearer token (`CALM_HTTP_TOKEN`), remote + bị `resolve_http_launch` ép **read-only** (`full,-edit`) + bắt buộc token — rất xa OAuth/CIMD. + +--- + +## 2. Bản đồ SEP → giá trị cho CALM (đánh giá phản biện, không tô hồng) + +| SEP | Thay đổi | Giá trị cho deployment thật của CALM | Việc | +|---|---|---|---| +| **2322** MRTR (`input_required`/`inputResponses`) | Thay elicitation/sampling server-initiated bằng round-trip stateless | **CAO** — future-proof gate an toàn, vá lỗ hổng stateless, khớp triết lý handle | **P2** | +| **2577** Deprecations (elicitation/roots/sampling/logging/SSE) | Cửa sổ 12 tháng | **CAO (bắt buộc)** cho elicitation; phần còn lại **N/A** | **P2** | +| **2549** Cacheable lists (`ttlMs`/`cacheScope`) | Cache `tools/list`,`prompts/list`,`resources/*` | **TRUNG BÌNH** — ~30 tool+schema gửi lại mỗi reconnect; hợp với dynamic toolset | **P3** | +| **2575/2567** Stateless core | Bỏ session, request rơi vào bất kỳ instance nào sau LB | **THẤP** (single-node, 1 DB, SINGLE_WRITER) — và là **hazard** cần quản lý, không phải lợi ích | **P4** | +| **2243** Header routing (`Mcp-Method`/`Mcp-Name`) | Gateway route không cần parse body | **THẤP** nhưng **MIỄN PHÍ** — rmcp 3.x tự phát/verify, 0 call-site | free ở **P1** | +| **2663** Tasks extension | `tasks/get`/`tasks/update` poll-based | **THẤP-TB** — CALM đã có handle polling giàu hơn; chỉ đáng khi thêm tool blocking dài | **P4** | +| **2468/837/2352** Auth hardening (RFC9207, application_type, credential binding, CIMD) | OAuth2/OIDC nghiêm chỉnh | **THẤP** cho single-user local; **CAO** *nếu* làm hosted/multi-user | **P4** | +| — | Structured output / output schema | **ĐÃ CÓ** | 0 | +| — | Đàm phán protocol 2026-07-28 (LATEST) | tự động khi lên rmcp 3.x | **P1** | + +--- + +## 3. Lộ trình chi tiết + +### Phase 0 — Spike de-risk (không đổi hành vi, ~0.5–1 ngày) + +Mục tiêu: gỡ 2 ẩn số lớn trước khi cam kết. + +1. **Resolution spike** (bắt buộc — theo đúng văn hoá "live-verified" của Cargo.toml hiện tại): trong + scratch, thử `rmcp = "3"` với đúng feature set (`server, transport-io, macros, elicitation, schemars` + + `transport-streamable-http-server`) cùng `axum 0.8` + `schemars 1`, chạy `cargo tree -p rmcp` và + `-p axum` để chắc **một** cây, build sạch. (Đây chính là loại lỗi Dependabot #46 từng gây — comment + OTEL trong Cargo.toml đã ghi bài học này.) +2. **Verify API surface 3.x trực tiếp trên crate** (không tin release-notes): xác nhận tên/thay đổi của + `ServerHandler::call_tool` (enum MRTR-aware / `InputRequiredResult`?), `StreamableHttpServerConfig` + (`legacy_session_mode` vs stateless), `serve_with_lifecycle`/`ClientLifecycleMode`, và trạng thái + deprecate của `elicit_with_timeout`/`supported_elicitation_modes`. Lập bảng "API CALM dùng → tương + đương 3.x". +3. **Chốt posture HTTP:** khuyến nghị **`legacy_session_mode` ở P1** để giữ nguyên state theo-connection + → tách rủi ro stateless khỏi giá trị MRTR. + +**Ra quyết định go/no-go:** nếu resolution bẩn hoặc 3.x quá non cho write-path → hoãn, chỉ giữ tài liệu +này làm sẵn sàng. + +### Phase 1 — Bump rmcp 2→3, giữ nguyên hành vi (compat floor) + +- Đổi pin `rmcp = "3"`; sửa mọi lỗi compile do breaking-change (chú ý các type `#[non_exhaustive]`: + `ServerInfo`/capabilities builder có thể thêm field bắt buộc mới). +- `call_tool`: nếu 3.x đổi kiểu trả về sang enum MRTR-aware, wrap kết quả hiện tại vào biến thể + "hoàn tất bình thường" — **chưa** phát `input_required` (đó là P2). +- HTTP: đặt `legacy_session_mode` để `LocalSessionManager` + state theo-connection chạy y như cũ. +- Elicitation: **giữ nguyên**, chấp nhận deprecation warning (sẽ thay ở P2). +- **Test:** thêm test đàm phán — client offer `2026-07-28` → server đàm phán 2026-07-28; client offer + `2024-11-05` (như `daemon_integration.rs` đang hardcode) → vẫn chạy (downgrade). Giữ toàn bộ + js-client-interop + daemon_integration xanh. +- **Thu hoạch miễn phí:** header routing (SEP-2243) tự bật; structured output không đổi. + +Kết thúc P1: CALM "nói" được 2026-07-28, không đổi một hành vi an toàn nào. + +### Phase 2 — Di trú human-veto gate sang MRTR (⭐ mỏ neo giá trị) + +Nguyên tắc vàng: **giữ `map_elicit_outcome` làm điểm quyết định fail-closed DUY NHẤT** — nó đã pure & +unit-tested; chỉ đổi *cơ chế vận chuyển* câu hỏi/đáp án, **không** đổi decision table. + +- Trên edit tools (`edit_lines`/`edit_symbol`), khi hub/high-risk gate kích hoạt: thay vì + `peer.elicit_with_timeout`, **trả `resultType: "input_required"`** mang câu hỏi phê duyệt đã + sanitize (tái dùng `build_hub_elicit_message` + lớp redaction FM3 — không được để reason thành bề mặt + injection vào UI người duyệt). +- Client re-invoke chính tool đó kèm `inputResponses`/payload `HubEditApproval{approve}`; server map + qua **đúng** `map_elicit_outcome` → giữ nguyên ngữ nghĩa: chỉ `approve:true` mới cho ghi, mọi thứ + khác fail-closed. +- **Dual-path trong cửa sổ deprecation:** protocol ≥2026-07-28 → MRTR; protocol cũ (client chưa lên) → + elicitation như cũ. Cả hai đổ về `map_elicit_outcome`, nên hành vi an toàn hội tụ và test tập trung. +- **Declined-cache** (per-connection): giữ trên stdio/daemon/legacy-HTTP. Ghi rõ: *nếu* sau này bật + stateless HTTP thì cache này phải thành explicit handle hoặc bỏ (không im lặng mất). +- **Test:** nhân bản bộ test elicitation hiện có sang MRTR — + `..._can_pass_via_elicitation_ask_then_approved`, `high_risk_edit_..._ask_then_approved`, + `..._is_refused_with_elicitation_off`, timeout/declined — để chứng minh MRTR round-trip cho **đúng** + verdict như elicitation. + +Kết thúc P2: gate an toàn chạy đồng nhất trên **mọi** transport, sống sót deprecation, và không còn tự +tắt lặng trên kết nối stateless. + +### Phase 3 — Cacheable tool/prompt lists (SEP-2549, hiệu quả token) + +- Thêm `ttlMs` + `cacheScope` vào kết quả `list_tools`/`list_prompts`. +- **`cacheScope` tối đa = per-connection** (KHÔNG global): tập tool nhìn thấy là per-connection (preset + + `set_toolset`). `set_toolset` **đã** phát `notify_tool_list_changed` (debounced) → dùng đúng tín hiệu + đó làm cache-bust. +- Đo token tiết kiệm trên reconnect (dùng benchmark b4_token_efficiency làm khung). + +### Phase 4 — Hoãn tới khi có nhu cầu hosted/multi-user + +Chỉ mở khi CALM muốn thành **dịch vụ chia sẻ/remote nhiều người**: +- **Stateless HTTP** (SEP-2575/2567): đưa toolset-narrowing + declined-cache thành explicit handle; + chuyển sang `serve_with_lifecycle`/`ClientLifecycleMode::Discover|Auto`. (edit-transaction vốn đã + explicit → phần khó nhất đã sẵn.) +- **OAuth/CIMD** (SEP-2468/837/2352): thay static bearer bằng OAuth2/OIDC — **điều kiện tiên quyết** cho + bất kỳ offering remote-write nào; hôm nay là overkill. +- **Tasks extension** (SEP-2663): chỉ đáng nếu thêm tool blocking chạy dài (vd "reindex"/"run SCIP + overlay" gọi như tool) — khi đó expose dưới `tasks/*` để client poll generic. Giữ các status tool + giàu domain-field hiện tại song song. + +--- + +## 4. Rủi ro & guardrail + +- **rmcp 3.x mới ~1 tuần, nằm trên write-path an toàn** → không ghim vội; giữ mọi fail-closed gate; + cân nhắc chờ 3.1.x/3.2 lắng; có thể gate MRTR sau cờ tính năng để rollback nhanh. +- **Type `#[non_exhaustive]`** (ServerInfo/capabilities) → build sớm ở P0/P1 để lộ field bắt buộc mới. +- **Đừng đổi `map_elicit_outcome`** khi di trú MRTR — mọi thay đổi vận chuyển phải hội tụ về đúng bảng + quyết định fail-closed đã test. +- **daemon_integration.rs hardcode 2024-11-05** — giữ làm test downgrade, **thêm** test 2026-07-28. +- **Resolution axum/schemars/OTEL** — lặp lại đúng sự cẩn trọng đã ghi trong comment Cargo.toml + (`cargo tree` một-cây) cho cả rmcp 3.x. +- **Đừng "adopt cho có":** stateless/OAuth/tasks không mang giá trị cho single-node hôm nay; adopt sớm + chỉ thêm bề mặt tấn công và bảo trì. + +## 5. Định nghĩa "xong" theo phase + +- P0: có bảng API-map 3.x đã verify trên crate + spike resolution build sạch + quyết định go/no-go. +- P1: `rmcp=3`, CI xanh toàn bộ, test đàm phán 2026-07-28 + downgrade 2024-11-05, 0 đổi hành vi an toàn. +- P2: gate an toàn chạy qua MRTR trên client 2026-07-28, dual-path cho client cũ, bộ test MRTR mirror + elicitation xanh, `map_elicit_outcome` không đổi. +- P3: list results mang `ttlMs`/`cacheScope` per-connection, cache-bust qua `tool_list_changed`, có số + đo token tiết kiệm. +- P4: (mở sau) chỉ khi có quyết định làm hosted.