From 5ee6c4b2bd0206a30be32707b8448f28bfa12161 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:20:51 +0800 Subject: [PATCH 1/2] fix(mcp): emit the cache hints 2026-07-28 requires on tools/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ListToolsResult::with_all_items` leaves `ttlMs` and `cacheScope` unset, and rmcp only strips `resultType` for legacy peers — nothing fills these in. Protocol 2026-07-28 made both fields required on `tools/list` (SEP-2549 `CacheableResult`), so a strict client rejects the response before it reaches a tool call: the TypeScript SDK 2.0.0 client fails with `Invalid result for tools/list: expected number, received undefined` at `ttlMs`. `server/discover` succeeds first, which makes it read like a client bug until you look at the wire. rmcp 3.1.1 fixes this for servers built from `#[tool_handler]` (modelcontextprotocol/rust-sdk#1120). This server hand-writes `ServerHandler::list_tools`, so the macro fix does not reach it. The tool set is derived from the clap tree once in `EcpMcpServer::new` and never changes while the process lives, so the hint is public and any TTL up to the session length would be honest; 60s matches rmcp's own conformance server and keeps a session that outlives an `ecp` upgrade from calling a subcommand the new binary dropped. Peers older than 2026-07-28 predate both fields and keep the legacy wire shape, matching how the macro gates them. Alongside: `cargo update` (rkyv 0.8.18, rmcp 3.1.1, clap 4.6.6, blake3 1.8.6 and their transitive set), and `taiki-e/install-action` to v2.85.10 — tool-manifest updates only, cargo-nextest 0.9.143 among them. --- .github/workflows/ci.yml | 4 +- Cargo.lock | 75 ++++++++++++++++--------------- crates/ecp-mcp/Cargo.toml | 2 +- crates/ecp-mcp/src/server.rs | 85 +++++++++++++++++++++++++++++++++++- 4 files changed, 123 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f302942..3356a3bd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,7 +272,7 @@ jobs: - name: Install cargo-nextest if: needs.detect-changes.outputs.code == 'true' - uses: taiki-e/install-action@cb33e69fad06166ca28a42b2575e4dadabf62ee8 # v2.85.8 + uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10 with: tool: cargo-nextest @@ -391,7 +391,7 @@ jobs: - name: Install cargo-audit & cargo-deny if: needs.detect-changes.outputs.code == 'true' - uses: taiki-e/install-action@cb33e69fad06166ca28a42b2575e4dadabf62ee8 # v2.85.8 + uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10 with: tool: cargo-audit,cargo-deny diff --git a/Cargo.lock b/Cargo.lock index b520d11df..c2844c86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,9 +41,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -231,9 +231,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", @@ -310,9 +310,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytecheck" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ "bytecheck_derive", "ptr_meta", @@ -322,13 +322,13 @@ dependencies = [ [[package]] name = "bytecheck_derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -435,9 +435,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -445,9 +445,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1167,9 +1167,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "finl_unicode" @@ -2442,22 +2442,22 @@ dependencies = [ [[package]] name = "ptr_meta" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2489,9 +2489,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rancor" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daff8b7b3ccf5f7ba270b3e7a0a4d4c701c5797e38dec27c7e2c3dbb830fed1c" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" dependencies = [ "ptr_meta", ] @@ -2739,9 +2739,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ "bytecheck", "bytes", @@ -2758,22 +2758,21 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "rmcp" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695" +checksum = "094c075f6698deef5a657cf4df6b684dff65157d255978b92b552ec22503f17a" dependencies = [ - "async-trait", "base64 0.23.1", "chrono", "futures", @@ -2792,15 +2791,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79" +checksum = "737d947bcfd946fae6a179a4ef6487be6dcf25c930c2393856b820f1386e52a6" dependencies = [ - "darling 0.23.0", + "darling 0.24.0", "proc-macro2", "quote", "serde_json", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -4659,18 +4658,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ecp-mcp/Cargo.toml b/crates/ecp-mcp/Cargo.toml index 4c2d02dfe..80bee9435 100644 --- a/crates/ecp-mcp/Cargo.toml +++ b/crates/ecp-mcp/Cargo.toml @@ -17,7 +17,7 @@ ecp-core = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "process"] } -rmcp = { version = "3", features = ["server", "transport-io"] } +rmcp = { version = "3.1", features = ["server", "transport-io"] } [dev-dependencies] clap = { version = "4.5", features = ["derive"] } diff --git a/crates/ecp-mcp/src/server.rs b/crates/ecp-mcp/src/server.rs index 189d8b0d8..7f12b7dea 100644 --- a/crates/ecp-mcp/src/server.rs +++ b/crates/ecp-mcp/src/server.rs @@ -85,12 +85,12 @@ impl rmcp::ServerHandler for RmcpHandler { fn list_tools( &self, _request: Option, - _context: rmcp::service::RequestContext, + context: rmcp::service::RequestContext, ) -> impl std::future::Future> + rmcp::service::MaybeSendFuture + '_ { let tools = self.0.rmcp_tools.to_vec(); - std::future::ready(Ok(rmcp::model::ListToolsResult::with_all_items(tools))) + std::future::ready(Ok(list_tools_result(tools, context.protocol_version()))) } fn call_tool( @@ -126,6 +126,35 @@ impl rmcp::ServerHandler for RmcpHandler { } } +/// How long a peer may treat one `tools/list` response as fresh. +/// +/// The tool set is derived once from the clap tree in `EcpMcpServer::new` and +/// never changes while the process lives, so any TTL up to the session length +/// would be honest. It stays short anyway: rmcp's client cache is keyed per +/// connection, and a spawn-mode session that outlives an `ecp` upgrade should +/// re-list rather than keep calling a subcommand the new binary dropped. +const TOOL_LIST_TTL_MS: u64 = 60_000; + +/// Attach the SEP-2549 cache hints that protocol `2026-07-28` requires on +/// `tools/list`. A strict client on that version rejects a response missing +/// `ttlMs` / `cacheScope` outright, before any tool call. The tool set is +/// derived from the binary alone — no per-user data — so the scope is public. +/// +/// Older peers predate both fields and keep the legacy wire shape, matching +/// how rmcp's own `#[tool_handler]` gates them. +fn list_tools_result( + tools: Vec, + protocol: Option, +) -> rmcp::model::ListToolsResult { + let result = rmcp::model::ListToolsResult::with_all_items(tools); + match protocol { + Some(version) if version >= rmcp::model::ProtocolVersion::V_2026_07_28 => result + .with_ttl_ms(TOOL_LIST_TTL_MS) + .with_cache_scope(rmcp::model::CacheScope::Public), + _ => result, + } +} + fn build_rmcp_tools(tools: &[DerivedTool]) -> Vec { tools .iter() @@ -143,6 +172,58 @@ fn build_rmcp_tools(tools: &[DerivedTool]) -> Vec { .collect() } +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::ProtocolVersion; + + fn wire(protocol: Option) -> serde_json::Value { + serde_json::to_value(list_tools_result(Vec::new(), protocol)).expect("serialize") + } + + #[test] + fn peer_on_2026_07_28_gets_the_required_cache_hints() { + let json = wire(Some(ProtocolVersion::V_2026_07_28)); + assert_eq!(json["ttlMs"], serde_json::json!(TOOL_LIST_TTL_MS)); + assert_eq!(json["cacheScope"], serde_json::json!("public")); + } + + /// The gate is `>=`, not `==`: a protocol newer than any this build knows + /// about still requires the fields. + #[test] + fn peer_on_a_later_version_still_gets_the_cache_hints() { + let future: ProtocolVersion = + serde_json::from_value(serde_json::json!("2027-01-01")).expect("parse version"); + let json = wire(Some(future)); + assert_eq!(json["ttlMs"], serde_json::json!(TOOL_LIST_TTL_MS)); + assert_eq!(json["cacheScope"], serde_json::json!("public")); + } + + #[test] + fn legacy_peer_keeps_the_wire_shape_that_predates_the_fields() { + for version in [ + ProtocolVersion::V_2025_11_25, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2024_11_05, + ] { + let json = wire(Some(version.clone())); + assert!( + json.get("ttlMs").is_none() && json.get("cacheScope").is_none(), + "cache hints leaked to legacy peer {version:?}: {json}" + ); + } + } + + #[test] + fn unnegotiated_peer_keeps_the_legacy_wire_shape() { + let json = wire(None); + assert!( + json.get("ttlMs").is_none() && json.get("cacheScope").is_none(), + "cache hints emitted without a negotiated version: {json}" + ); + } +} + pub async fn serve_stdio(server: EcpMcpServer) -> anyhow::Result<()> { let handler = RmcpHandler(Arc::new(server)); let transport = rmcp::transport::stdio(); From 3485f169bbfc7361d58fde1b8780a0144a78ecb7 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:22:55 +0800 Subject: [PATCH 2/2] fix(mcp): keep serve_stdio above the test module clippy::items_after_test_module (-D warnings in CI) rejects any item declared after a `#[cfg(test)] mod`. --- crates/ecp-mcp/src/server.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ecp-mcp/src/server.rs b/crates/ecp-mcp/src/server.rs index 7f12b7dea..4a116d29f 100644 --- a/crates/ecp-mcp/src/server.rs +++ b/crates/ecp-mcp/src/server.rs @@ -172,6 +172,14 @@ fn build_rmcp_tools(tools: &[DerivedTool]) -> Vec { .collect() } +pub async fn serve_stdio(server: EcpMcpServer) -> anyhow::Result<()> { + let handler = RmcpHandler(Arc::new(server)); + let transport = rmcp::transport::stdio(); + let running = rmcp::serve_server(handler, transport).await?; + running.waiting().await.ok(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -223,11 +231,3 @@ mod tests { ); } } - -pub async fn serve_stdio(server: EcpMcpServer) -> anyhow::Result<()> { - let handler = RmcpHandler(Arc::new(server)); - let transport = rmcp::transport::stdio(); - let running = rmcp::serve_server(handler, transport).await?; - running.waiting().await.ok(); - Ok(()) -}