From 6fa0d23c1180bb8c5d8261916449c1abedf3cf74 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:25:54 +0100 Subject: [PATCH 01/48] ci: apply and validate issue 974 implementation --- .github/workflows/apply-issue-974.yml | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/apply-issue-974.yml diff --git a/.github/workflows/apply-issue-974.yml b/.github/workflows/apply-issue-974.yml new file mode 100644 index 00000000..36c8c602 --- /dev/null +++ b/.github/workflows/apply-issue-974.yml @@ -0,0 +1,71 @@ +name: Apply and validate issue 974 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/apply-issue-974.yml + +permissions: + contents: write + +jobs: + apply-and-validate: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Download validated implementation patch + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/rust-sdk-issue-974-cache-draft.patch \ + --output /tmp/issue-974.patch + + - name: Apply implementation + run: | + git apply --check /tmp/issue-974.patch + git apply /tmp/issue-974.patch + + - name: Format and verify source + run: | + cargo fmt --all + cargo fmt --all -- --check + git diff --check + + - name: Compile all rmcp features + run: cargo check -p rmcp --all-features + + - name: Run focused cache tests + run: cargo test -p rmcp --all-features service::client::tests -- --nocapture + + - name: Run cache-hint integration tests + run: cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + + - name: Run complete rmcp test suite + run: cargo test -p rmcp --all-features + + - name: Run Clippy + run: cargo clippy -p rmcp --all-targets --all-features -- -D warnings + + - name: Commit validated implementation + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + crates/rmcp/src/service.rs \ + crates/rmcp/src/service/client.rs \ + crates/rmcp/src/handler/client.rs + git commit -m "feat(client): honor TTL cache hints" + git push origin HEAD:feat/issue-974-client-response-cache From 94d5e5a2cbb1be61d3a2f931c1b179f9372f548a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:28:32 +0000 Subject: [PATCH 02/48] feat(client): honor TTL cache hints --- crates/rmcp/src/handler/client.rs | 4 + crates/rmcp/src/service.rs | 58 ++++++ crates/rmcp/src/service/client.rs | 297 +++++++++++++++++++++++++++++- 3 files changed, 353 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index c9097e24..c9d1c396 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -55,15 +55,19 @@ impl Service for H { self.on_logging_message(notification.params, context).await } ServerNotification::ResourceUpdatedNotification(notification) => { + context.peer.invalidate_resource_read_cache().await; self.on_resource_updated(notification.params, context).await } ServerNotification::ResourceListChangedNotification(_notification_no_param) => { + context.peer.invalidate_resource_list_cache().await; self.on_resource_list_changed(context).await } ServerNotification::ToolListChangedNotification(_notification_no_param) => { + context.peer.invalidate_tool_cache().await; self.on_tool_list_changed(context).await } ServerNotification::PromptListChangedNotification(_notification_no_param) => { + context.peer.invalidate_prompt_cache().await; self.on_prompt_list_changed(context).await } ServerNotification::TaskStatusNotification(notification) => { diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 2345a5e4..8a8f71f7 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -266,6 +266,8 @@ impl> DynService for S { } } +#[cfg(feature = "client")] +use std::time::Instant; use std::{ collections::{HashMap, VecDeque}, ops::Deref, @@ -338,6 +340,17 @@ impl ProgressNotificationToken for ServerNotification { type Responder = tokio::sync::oneshot::Sender; type ProgressTimeoutWatchers = Arc>>>; +#[cfg(feature = "client")] +#[derive(Debug, Clone)] +struct CachedPeerResponse { + value: T, + expires_at: Instant, +} + +#[cfg(feature = "client")] +type PeerResponseCache = + Arc::PeerResp>>>>; + /// A handle to a remote request /// /// You can cancel it by call [`RequestHandle::cancel`] with a reason, @@ -527,6 +540,8 @@ pub struct Peer { progress_token_provider: Arc, progress_timeout_watchers: ProgressTimeoutWatchers, info: Arc>>>, + #[cfg(feature = "client")] + response_cache: PeerResponseCache, } impl std::fmt::Debug for Peer { @@ -588,6 +603,8 @@ impl Peer { progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), progress_timeout_watchers: Default::default(), info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), + #[cfg(feature = "client")] + response_cache: Default::default(), }, rx, ) @@ -706,6 +723,47 @@ impl Peer { pub fn is_transport_closed(&self) -> bool { self.tx.is_closed() } + + /// Return a fresh cached peer response and remove an expired entry on access. + /// + /// The cache belongs to one `Peer`, so cloned handles share it while separate + /// client connections and authorization contexts remain isolated. + #[cfg(feature = "client")] + pub(crate) async fn cached_response(&self, key: &str) -> Option { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if let Some(entry) = cache.get(key) + && entry.expires_at > now + { + return Some(entry.value.clone()); + } + cache.remove(key); + None + } + + /// Store a response for a positive TTL. Expired entries are opportunistically + /// removed so a client that sees many one-off keys does not retain stale pages. + #[cfg(feature = "client")] + pub(crate) async fn cache_response(&self, key: String, value: R::PeerResp, ttl: Duration) { + if ttl.is_zero() { + return; + } + let now = Instant::now(); + let Some(expires_at) = now.checked_add(ttl) else { + return; + }; + let mut cache = self.response_cache.write().await; + cache.retain(|_, entry| entry.expires_at > now); + cache.insert(key, CachedPeerResponse { value, expires_at }); + } + + #[cfg(feature = "client")] + pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { + self.response_cache + .write() + .await + .retain(|key, _| !key.starts_with(prefix)); + } } #[derive(Debug)] diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 92951261..4703bfdf 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -273,6 +273,32 @@ where Ok(serve_inner(service, transport, peer, peer_rx, ct)) } +const TOOL_LIST_CACHE_PREFIX: &str = "tools/list:"; +const PROMPT_LIST_CACHE_PREFIX: &str = "prompts/list:"; +const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; +const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; +const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; + +fn response_cache_key(prefix: &str, params: &T) -> Option { + let value = serde_json::to_value(params).ok()?; + + // SEP-2322 retry rounds may contain user-specific input and request state. + // Never cache those rounds, even when a server attaches a TTL to the final result. + let is_mrtr_retry = value + .get("inputResponses") + .is_some_and(|value| !value.is_null()) + || value + .get("requestState") + .is_some_and(|value| !value.is_null()); + if is_mrtr_retry { + return None; + } + + serde_json::to_string(&value) + .ok() + .map(|params| format!("{prefix}{params}")) +} + macro_rules! method { ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { $(#[$meta])* @@ -363,6 +389,36 @@ macro_rules! method { } impl Peer { + async fn cache_result(&self, key: Option, ttl_ms: Option, result: ServerResult) { + let (Some(key), Some(ttl_ms)) = (key, ttl_ms.filter(|ttl_ms| *ttl_ms > 0)) else { + return; + }; + self.cache_response(key, result, Duration::from_millis(ttl_ms)) + .await; + } + + pub(crate) async fn invalidate_tool_cache(&self) { + self.invalidate_cached_responses(TOOL_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_prompt_cache(&self) { + self.invalidate_cached_responses(PROMPT_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_list_cache(&self) { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_read_cache(&self) { + self.invalidate_cached_responses(RESOURCE_READ_CACHE_PREFIX) + .await; + } + /// Send one `tools/call` request and return either a final result or an MRTR /// `InputRequiredResult` without driving any follow-up rounds. pub async fn call_tool_once( @@ -413,6 +469,13 @@ impl Peer { &self, params: ReadResourceRequestParams, ) -> Result { + let cache_key = response_cache_key(RESOURCE_READ_CACHE_PREFIX, ¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ReadResourceResult(result)) = self.cached_response(key).await + { + return Ok(ReadResourceResponse::Complete(result)); + } + let result = self .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { method: Default::default(), @@ -421,7 +484,15 @@ impl Peer { })) .await?; match result { - ServerResult::ReadResourceResult(result) => Ok(ReadResourceResponse::Complete(result)), + ServerResult::ReadResourceResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + ServerResult::ReadResourceResult(result.clone()), + ) + .await; + Ok(ReadResourceResponse::Complete(result)) + } ServerResult::InputRequiredResult(result) => { Ok(ReadResourceResponse::InputRequired(result)) } @@ -438,14 +509,146 @@ impl Peer { peer_req set_level SetLevelRequest(SetLevelRequestParams) ); method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); - method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParams)? => ListPromptsResult); - method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult); - method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult); - method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult); method!(peer_req subscribe SubscribeRequest(SubscribeRequestParams) ); method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams)); method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); - method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult); + + pub async fn list_prompts( + &self, + params: Option, + ) -> Result { + let cache_key = response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ListPromptsResult(result)) = self.cached_response(key).await + { + return Ok(result); + } + let result = self + .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::ListPromptsResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + ServerResult::ListPromptsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resources( + &self, + params: Option, + ) -> Result { + let cache_key = response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ListResourcesResult(result)) = self.cached_response(key).await + { + return Ok(result); + } + let result = self + .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::ListResourcesResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + ServerResult::ListResourcesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resource_templates( + &self, + params: Option, + ) -> Result { + let cache_key = response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.cached_response(key).await + { + return Ok(result); + } + let result = self + .send_request(ClientRequest::ListResourceTemplatesRequest( + ListResourceTemplatesRequest { + method: Default::default(), + params, + extensions: Default::default(), + }, + )) + .await?; + match result { + ServerResult::ListResourceTemplatesResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + ServerResult::ListResourceTemplatesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + ) -> Result { + match self.read_resource_once(params).await? { + ReadResourceResponse::Complete(result) => Ok(result), + ReadResourceResponse::InputRequired(_) => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_tools( + &self, + params: Option, + ) -> Result { + let cache_key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ListToolsResult(result)) = self.cached_response(key).await + { + return Ok(result); + } + let result = self + .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::ListToolsResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + ServerResult::ListToolsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -917,3 +1120,85 @@ where )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn disconnected_peer() -> Peer { + let (peer, receiver) = + Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); + drop(receiver); + peer + } + + #[tokio::test] + async fn list_tools_returns_a_fresh_cached_page_without_transport_io() { + let peer = disconnected_peer(); + let params = None::; + let key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); + let expected = ListToolsResult::with_all_items(Vec::new()).with_ttl_ms(5_000); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + Duration::from_secs(5), + ) + .await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn expired_entries_fall_through_to_the_transport() { + let peer = disconnected_peer(); + let params = None::; + let key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); + let result = ListToolsResult::with_all_items(Vec::new()).with_ttl_ms(1); + peer.cache_response( + key, + ServerResult::ListToolsResult(result), + Duration::from_millis(1), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn tool_invalidation_does_not_remove_prompt_pages() { + let peer = disconnected_peer(); + let params = None::; + let tool_key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); + let prompt_key = response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms).unwrap(); + peer.cache_response( + tool_key.clone(), + ServerResult::ListToolsResult(ListToolsResult::with_all_items(Vec::new())), + Duration::from_secs(5), + ) + .await; + peer.cache_response( + prompt_key.clone(), + ServerResult::ListPromptsResult(ListPromptsResult::with_all_items(Vec::new())), + Duration::from_secs(5), + ) + .await; + + peer.invalidate_tool_cache().await; + + assert!(peer.cached_response(&tool_key).await.is_none()); + assert!(peer.cached_response(&prompt_key).await.is_some()); + } + + #[test] + fn mrtr_retry_parameters_are_not_cacheable() { + let params = serde_json::json!({ + "uri": "file:///example.txt", + "inputResponses": { "approval": true } + }); + assert!(response_cache_key(RESOURCE_READ_CACHE_PREFIX, ¶ms).is_none()); + } +} From b9aaf640a8e2ad087b42801c286a7ce3a82bca43 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:06 +0100 Subject: [PATCH 03/48] chore: remove issue 974 validation workflow --- .github/workflows/apply-issue-974.yml | 71 --------------------------- 1 file changed, 71 deletions(-) delete mode 100644 .github/workflows/apply-issue-974.yml diff --git a/.github/workflows/apply-issue-974.yml b/.github/workflows/apply-issue-974.yml deleted file mode 100644 index 36c8c602..00000000 --- a/.github/workflows/apply-issue-974.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Apply and validate issue 974 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/apply-issue-974.yml - -permissions: - contents: write - -jobs: - apply-and-validate: - runs-on: ubuntu-latest - timeout-minutes: 60 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Download validated implementation patch - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/rust-sdk-issue-974-cache-draft.patch \ - --output /tmp/issue-974.patch - - - name: Apply implementation - run: | - git apply --check /tmp/issue-974.patch - git apply /tmp/issue-974.patch - - - name: Format and verify source - run: | - cargo fmt --all - cargo fmt --all -- --check - git diff --check - - - name: Compile all rmcp features - run: cargo check -p rmcp --all-features - - - name: Run focused cache tests - run: cargo test -p rmcp --all-features service::client::tests -- --nocapture - - - name: Run cache-hint integration tests - run: cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - - - name: Run complete rmcp test suite - run: cargo test -p rmcp --all-features - - - name: Run Clippy - run: cargo clippy -p rmcp --all-targets --all-features -- -D warnings - - - name: Commit validated implementation - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - crates/rmcp/src/service.rs \ - crates/rmcp/src/service/client.rs \ - crates/rmcp/src/handler/client.rs - git commit -m "feat(client): honor TTL cache hints" - git push origin HEAD:feat/issue-974-client-response-cache From 65500cd684db043eff4287f9acc22a7f05e64a61 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:37:17 +0100 Subject: [PATCH 04/48] ci: harden and validate issue 974 cache --- .github/workflows/harden-issue-974.yml | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/harden-issue-974.yml diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml new file mode 100644 index 00000000..44cd2787 --- /dev/null +++ b/.github/workflows/harden-issue-974.yml @@ -0,0 +1,113 @@ +name: Harden and validate issue 974 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/harden-issue-974.yml + +permissions: + contents: write + +jobs: + harden-and-validate: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Check out issue branch + uses: actions/checkout@v7 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install stable Rust with Clippy + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Apply hardening changes + env: + SCRIPT_GZ_B64: >- + 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 + run: | + printf '%s' "$SCRIPT_GZ_B64" | base64 --decode | gzip --decompress > /tmp/apply_issue_974_hardening.py + python /tmp/apply_issue_974_hardening.py + + - name: Format with repository toolchain + run: cargo +nightly fmt --all + + - name: Verify formatting and whitespace + run: | + cargo +nightly fmt --all -- --check + git diff --check + + - name: Check workspace + run: cargo check --workspace --all-targets --all-features + + - name: Run workspace Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run all-feature tests + run: cargo test --all-features + + - name: Run rmcp tests without local feature + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + + - name: Build documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc -p rmcp --all-features --no-deps + + - name: Build conformance binaries + run: cargo build -p mcp-conformance + + - name: Run spelling check + uses: crate-ci/typos@master + + - name: Record success + if: success() + run: | + cat > issue-974-validation-result.txt <<'EOF' + nightly_fmt=success + whitespace=success + workspace_check=success + workspace_clippy=success + all_feature_tests=success + rmcp_no_local_tests=success + docs=success + conformance_build=success + typos=success + EOF + + - name: Record failure + if: failure() + run: | + cat > issue-974-validation-result.txt <<'EOF' + validation=failed + Inspect the GitHub Actions job logs for the first failing step. + EOF + + - name: Commit implementation and validation result + if: always() + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + crates/rmcp/src/service.rs \ + crates/rmcp/src/service/client.rs \ + crates/rmcp/src/service/client/cache.rs \ + crates/rmcp/src/handler/client.rs \ + crates/rmcp/CHANGELOG.md \ + docs/CLIENT_CACHING.md \ + issue-974-validation-result.txt + git commit -m "feat(client): complete SEP-2549 response caching" || true + git push origin HEAD:feat/issue-974-client-response-cache From 17739013193edc6fa388595641a257b4591e8c8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:39:17 +0000 Subject: [PATCH 05/48] feat(client): complete SEP-2549 response caching --- crates/rmcp/CHANGELOG.md | 4 + crates/rmcp/src/handler/client.rs | 5 +- crates/rmcp/src/service.rs | 56 +--- crates/rmcp/src/service/client.rs | 411 +++++++++++++++++++----- crates/rmcp/src/service/client/cache.rs | 265 +++++++++++++++ docs/CLIENT_CACHING.md | 41 +++ issue-974-validation-result.txt | 2 + 7 files changed, 650 insertions(+), 134 deletions(-) create mode 100644 crates/rmcp/src/service/client/cache.rs create mode 100644 docs/CLIENT_CACHING.md create mode 100644 issue-974-validation-result.txt diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 1b6c212e..3ee9a3c3 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- add a configurable SEP-2549 client response cache with TTL, scope, pagination, and notification invalidation support + ## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08 ### Added diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index c9d1c396..5a8a22a3 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -55,7 +55,10 @@ impl Service for H { self.on_logging_message(notification.params, context).await } ServerNotification::ResourceUpdatedNotification(notification) => { - context.peer.invalidate_resource_read_cache().await; + context + .peer + .invalidate_resource_read_cache(¬ification.params.uri) + .await; self.on_resource_updated(notification.params, context).await } ServerNotification::ResourceListChangedNotification(_notification_no_param) => { diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 8a8f71f7..046121ce 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -266,8 +266,6 @@ impl> DynService for S { } } -#[cfg(feature = "client")] -use std::time::Instant; use std::{ collections::{HashMap, VecDeque}, ops::Deref, @@ -340,17 +338,6 @@ impl ProgressNotificationToken for ServerNotification { type Responder = tokio::sync::oneshot::Sender; type ProgressTimeoutWatchers = Arc>>>; -#[cfg(feature = "client")] -#[derive(Debug, Clone)] -struct CachedPeerResponse { - value: T, - expires_at: Instant, -} - -#[cfg(feature = "client")] -type PeerResponseCache = - Arc::PeerResp>>>>; - /// A handle to a remote request /// /// You can cancel it by call [`RequestHandle::cancel`] with a reason, @@ -541,7 +528,7 @@ pub struct Peer { progress_timeout_watchers: ProgressTimeoutWatchers, info: Arc>>>, #[cfg(feature = "client")] - response_cache: PeerResponseCache, + response_cache: client::cache::PeerResponseCache, } impl std::fmt::Debug for Peer { @@ -723,47 +710,6 @@ impl Peer { pub fn is_transport_closed(&self) -> bool { self.tx.is_closed() } - - /// Return a fresh cached peer response and remove an expired entry on access. - /// - /// The cache belongs to one `Peer`, so cloned handles share it while separate - /// client connections and authorization contexts remain isolated. - #[cfg(feature = "client")] - pub(crate) async fn cached_response(&self, key: &str) -> Option { - let now = Instant::now(); - let mut cache = self.response_cache.write().await; - if let Some(entry) = cache.get(key) - && entry.expires_at > now - { - return Some(entry.value.clone()); - } - cache.remove(key); - None - } - - /// Store a response for a positive TTL. Expired entries are opportunistically - /// removed so a client that sees many one-off keys does not retain stale pages. - #[cfg(feature = "client")] - pub(crate) async fn cache_response(&self, key: String, value: R::PeerResp, ttl: Duration) { - if ttl.is_zero() { - return; - } - let now = Instant::now(); - let Some(expires_at) = now.checked_add(ttl) else { - return; - }; - let mut cache = self.response_cache.write().await; - cache.retain(|_, entry| entry.expires_at > now); - cache.insert(key, CachedPeerResponse { value, expires_at }); - } - - #[cfg(feature = "client")] - pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { - self.response_cache - .write() - .await - .retain(|key, _| !key.starts_with(prefix)); - } } #[derive(Debug)] diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 4703bfdf..caffd3a6 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1,26 +1,30 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] + +pub(super) mod cache; use std::{borrow::Cow, sync::Arc, time::Duration}; +pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL}; use thiserror::Error; use super::*; use crate::{ model::{ - ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, - CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, - ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, ErrorData, - GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, - GetPromptResult, InitializeRequest, InitializedNotification, InputRequest, - InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, - ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, - ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, - NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, - ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, - Reference, RequestId, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, - ServerNotification, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, - SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, + ArgumentInfo, CacheScope, CallToolRequest, CallToolRequestParams, CallToolResponse, + CallToolResult, CancelledNotification, CancelledNotificationParam, ClientInfo, + ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, + CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, + DEFAULT_MRTR_MAX_ROUNDS, ErrorData, GetExtensions, GetMeta, GetPromptRequest, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, InitializeRequest, + InitializedNotification, InputRequest, InputRequiredResult, InputResponses, + JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, + ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, + ProgressNotificationParam, ReadResourceRequest, ReadResourceRequestParams, + ReadResourceResponse, ReadResourceResult, Reference, RequestId, + RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, + ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, + SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, }, transport::DynamicTransportError, }; @@ -279,24 +283,30 @@ const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; -fn response_cache_key(prefix: &str, params: &T) -> Option { - let value = serde_json::to_value(params).ok()?; - - // SEP-2322 retry rounds may contain user-specific input and request state. - // Never cache those rounds, even when a server attaches a TTL to the final result. - let is_mrtr_retry = value - .get("inputResponses") - .is_some_and(|value| !value.is_null()) - || value - .get("requestState") - .is_some_and(|value| !value.is_null()); - if is_mrtr_retry { +fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = serde_json::to_string(&cursor) + .expect("serializing an optional pagination cursor cannot fail"); + format!("{prefix}{cursor}") +} + +fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { return None; } + Some(resource_read_cache_key_for_uri(¶ms.uri)) +} - serde_json::to_string(&value) - .ok() - .map(|params| format!("{prefix}{params}")) +fn resource_read_cache_key_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") +} + +fn request_uses_cursor(params: &Option) -> bool { + params + .as_ref() + .and_then(|params| params.cursor.as_ref()) + .is_some() } macro_rules! method { @@ -389,11 +399,17 @@ macro_rules! method { } impl Peer { - async fn cache_result(&self, key: Option, ttl_ms: Option, result: ServerResult) { - let (Some(key), Some(ttl_ms)) = (key, ttl_ms.filter(|ttl_ms| *ttl_ms > 0)) else { + async fn cache_result( + &self, + cache_key: Option, + ttl_ms: Option, + cache_scope: Option, + result: ServerResult, + ) { + let Some(cache_key) = cache_key else { return; }; - self.cache_response(key, result, Duration::from_millis(ttl_ms)) + self.cache_response(cache_key, result, ttl_ms, cache_scope) .await; } @@ -414,8 +430,8 @@ impl Peer { .await; } - pub(crate) async fn invalidate_resource_read_cache(&self) { - self.invalidate_cached_responses(RESOURCE_READ_CACHE_PREFIX) + pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) .await; } @@ -469,7 +485,7 @@ impl Peer { &self, params: ReadResourceRequestParams, ) -> Result { - let cache_key = response_cache_key(RESOURCE_READ_CACHE_PREFIX, ¶ms); + let cache_key = resource_read_cache_key(¶ms); if let Some(key) = cache_key.as_deref() && let Some(ServerResult::ReadResourceResult(result)) = self.cached_response(key).await { @@ -488,6 +504,7 @@ impl Peer { self.cache_result( cache_key, result.ttl_ms, + result.cache_scope, ServerResult::ReadResourceResult(result.clone()), ) .await; @@ -517,24 +534,29 @@ impl Peer { &self, params: Option, ) -> Result { - let cache_key = response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ListPromptsResult(result)) = self.cached_response(key).await + let cache_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListPromptsResult(result)) = + self.cached_response(&cache_key).await { return Ok(result); } + let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { method: Default::default(), params, extensions: Default::default(), })) - .await?; - match result { + .await; + if result.is_err() && uses_cursor { + self.invalidate_prompt_cache().await; + } + match result? { ServerResult::ListPromptsResult(result) => { self.cache_result( - cache_key, + Some(cache_key), result.ttl_ms, + result.cache_scope, ServerResult::ListPromptsResult(result.clone()), ) .await; @@ -548,24 +570,30 @@ impl Peer { &self, params: Option, ) -> Result { - let cache_key = response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ListResourcesResult(result)) = self.cached_response(key).await + let cache_key = list_response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourcesResult(result)) = + self.cached_response(&cache_key).await { return Ok(result); } + let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { method: Default::default(), params, extensions: Default::default(), })) - .await?; - match result { + .await; + if result.is_err() && uses_cursor { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + } + match result? { ServerResult::ListResourcesResult(result) => { self.cache_result( - cache_key, + Some(cache_key), result.ttl_ms, + result.cache_scope, ServerResult::ListResourcesResult(result.clone()), ) .await; @@ -579,13 +607,13 @@ impl Peer { &self, params: Option, ) -> Result { - let cache_key = response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ListResourceTemplatesResult(result)) = - self.cached_response(key).await + let cache_key = list_response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.cached_response(&cache_key).await { return Ok(result); } + let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListResourceTemplatesRequest( ListResourceTemplatesRequest { @@ -594,12 +622,17 @@ impl Peer { extensions: Default::default(), }, )) - .await?; - match result { + .await; + if result.is_err() && uses_cursor { + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + } + match result? { ServerResult::ListResourceTemplatesResult(result) => { self.cache_result( - cache_key, + Some(cache_key), result.ttl_ms, + result.cache_scope, ServerResult::ListResourceTemplatesResult(result.clone()), ) .await; @@ -623,24 +656,28 @@ impl Peer { &self, params: Option, ) -> Result { - let cache_key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ListToolsResult(result)) = self.cached_response(key).await + let cache_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListToolsResult(result)) = self.cached_response(&cache_key).await { return Ok(result); } + let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { method: Default::default(), params, extensions: Default::default(), })) - .await?; - match result { + .await; + if result.is_err() && uses_cursor { + self.invalidate_tool_cache().await; + } + match result? { ServerResult::ListToolsResult(result) => { self.cache_result( - cache_key, + Some(cache_key), result.ttl_ms, + result.cache_scope, ServerResult::ListToolsResult(result.clone()), ) .await; @@ -1132,16 +1169,24 @@ mod tests { peer } + fn tools_result(ttl_ms: Option, cache_scope: Option) -> ListToolsResult { + let mut result = ListToolsResult::with_all_items(Vec::new()); + result.ttl_ms = ttl_ms; + result.cache_scope = cache_scope; + result + } + #[tokio::test] async fn list_tools_returns_a_fresh_cached_page_without_transport_io() { let peer = disconnected_peer(); let params = None::; - let key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); - let expected = ListToolsResult::with_all_items(Vec::new()).with_ttl_ms(5_000); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(Some(5_000), Some(CacheScope::Public)); peer.cache_response( key, ServerResult::ListToolsResult(expected.clone()), - Duration::from_secs(5), + expected.ttl_ms, + expected.cache_scope, ) .await; @@ -1152,12 +1197,13 @@ mod tests { async fn expired_entries_fall_through_to_the_transport() { let peer = disconnected_peer(); let params = None::; - let key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); - let result = ListToolsResult::with_all_items(Vec::new()).with_ttl_ms(1); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let result = tools_result(Some(1), Some(CacheScope::Public)); peer.cache_response( key, ServerResult::ListToolsResult(result), - Duration::from_millis(1), + Some(1), + Some(CacheScope::Public), ) .await; tokio::time::sleep(Duration::from_millis(5)).await; @@ -1169,21 +1215,232 @@ mod tests { } #[tokio::test] - async fn tool_invalidation_does_not_remove_prompt_pages() { + async fn disabled_cache_does_not_store_entries() { + let peer = disconnected_peer(); + peer.set_response_cache_config(ClientCacheConfig::disabled()) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn default_ttl_caches_results_without_server_ttl() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_default_ttl(Duration::from_secs(5)), + ) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(None, Some(CacheScope::Public)); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + None, + expected.cache_scope, + ) + .await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn max_ttl_caps_server_hint() { let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; let params = None::; - let tool_key = response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms).unwrap(); - let prompt_key = response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms).unwrap(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() { + let first = disconnected_peer(); + let second = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + first + .cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + + assert!(first.cached_response(&key).await.is_some()); + assert!(second.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn changing_private_partition_drops_private_entries_but_keeps_public_entries() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("principal-a"), + ) + .await; + let private_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + let public_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, &None); + peer.cache_response( + private_key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + peer.cache_response( + public_key.clone(), + ServerResult::ListPromptsResult( + ListPromptsResult::with_all_items(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public), + ), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("principal-b"), + ) + .await; + + assert!(peer.cached_response(&private_key).await.is_none()); + assert!(peer.cached_response(&public_key).await.is_some()); + } + + #[tokio::test] + async fn missing_scope_is_cached_in_the_private_partition() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("principal-a"), + ) + .await; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), None)), + Some(5_000), + None, + ) + .await; + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("principal-b"), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + + #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + } + + #[tokio::test] + async fn list_invalidation_discards_every_cached_page() { + let peer = disconnected_peer(); + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + } + + peer.invalidate_tool_cache().await; + + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + assert!(peer.cached_response(&key).await.is_none()); + } + } + + #[tokio::test] + async fn resource_update_invalidates_only_the_matching_uri() { + let peer = disconnected_peer(); + let first_key = resource_read_cache_key_for_uri("file:///first"); + let second_key = resource_read_cache_key_for_uri("file:///second"); + for key in [&first_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + } + + #[tokio::test] + async fn tool_invalidation_does_not_remove_prompt_pages() { + let peer = disconnected_peer(); + let tool_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + let prompt_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, &None); peer.cache_response( tool_key.clone(), - ServerResult::ListToolsResult(ListToolsResult::with_all_items(Vec::new())), - Duration::from_secs(5), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), ) .await; peer.cache_response( prompt_key.clone(), - ServerResult::ListPromptsResult(ListPromptsResult::with_all_items(Vec::new())), - Duration::from_secs(5), + ServerResult::ListPromptsResult( + ListPromptsResult::with_all_items(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public), + ), + Some(5_000), + Some(CacheScope::Public), ) .await; @@ -1195,10 +1452,8 @@ mod tests { #[test] fn mrtr_retry_parameters_are_not_cacheable() { - let params = serde_json::json!({ - "uri": "file:///example.txt", - "inputResponses": { "approval": true } - }); - assert!(response_cache_key(RESOURCE_READ_CACHE_PREFIX, ¶ms).is_none()); + let params = ReadResourceRequestParams::new("file:///example.txt") + .with_request_state("opaque-state"); + assert!(resource_read_cache_key(¶ms).is_none()); } } diff --git a/crates/rmcp/src/service/client/cache.rs b/crates/rmcp/src/service/client/cache.rs new file mode 100644 index 00000000..4c70c338 --- /dev/null +++ b/crates/rmcp/src/service/client/cache.rs @@ -0,0 +1,265 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use super::RoleClient; +use crate::{ + model::CacheScope, + service::{Peer, ServiceRole}, +}; + +/// Maximum server-provided cache TTL honoured by the client response cache. +pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Configuration for the built-in MCP client response cache. +/// +/// A cache is allocated per client [`Peer`]. Public responses may be reused +/// throughout that client connection. Private responses are additionally +/// partitioned by `private_partition`; changing the partition drops every +/// private entry while preserving public entries. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ClientCacheConfig { + /// Enables cache reads and writes. + pub enabled: bool, + /// TTL used when a backwards-compatible server omits `ttlMs`. + /// + /// The default is zero, which leaves such responses immediately stale. + pub default_ttl: Duration, + /// Upper bound applied to both server-provided and default TTLs. + pub max_ttl: Duration, + /// Stable opaque identity for the current authorization context. + /// + /// A single-principal client may leave this unset because each client owns + /// its own in-memory store. Gateways or clients that change principals on an + /// existing connection should set this value and update it whenever the + /// authorization context changes. + pub private_partition: Option, +} + +impl Default for ClientCacheConfig { + fn default() -> Self { + Self { + enabled: true, + default_ttl: Duration::ZERO, + max_ttl: MAX_CLIENT_CACHE_TTL, + private_partition: None, + } + } +} + +impl ClientCacheConfig { + /// Returns a configuration that disables all cache reads and writes. + pub fn disabled() -> Self { + Self { + enabled: false, + ..Self::default() + } + } + + /// Sets the TTL used when a response omits `ttlMs`. + pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self { + self.default_ttl = default_ttl; + self + } + + /// Sets the maximum TTL the client will honour. + pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self { + self.max_ttl = max_ttl; + self + } + + /// Sets the stable partition for private responses. + pub fn with_private_partition(mut self, partition: impl Into) -> Self { + self.private_partition = Some(partition.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum CachePartition { + Public, + Private(Arc), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + logical_key: String, + partition: CachePartition, +} + +#[derive(Debug, Clone)] +struct CachedPeerResponse { + value: T, + expires_at: Instant, + scope: CacheScope, +} + +#[derive(Debug)] +pub(crate) struct PeerResponseCacheState { + entries: HashMap>, + config: ClientCacheConfig, +} + +impl Default for PeerResponseCacheState { + fn default() -> Self { + Self { + entries: HashMap::new(), + config: ClientCacheConfig::default(), + } + } +} + +pub(crate) type PeerResponseCache = Arc>>; + +impl Peer { + fn private_partition(config: &ClientCacheConfig) -> Arc { + Arc::from(config.private_partition.as_deref().unwrap_or("connection")) + } + + fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey { + CacheKey { + logical_key: logical_key.to_owned(), + partition, + } + } + + fn scoped_cache_key( + logical_key: &str, + scope: CacheScope, + config: &ClientCacheConfig, + ) -> CacheKey { + let partition = match scope { + CacheScope::Public => CachePartition::Public, + CacheScope::Private => CachePartition::Private(Self::private_partition(config)), + }; + Self::cache_key(logical_key, partition) + } + + /// Returns a fresh cached response, preferring the current private partition + /// before the public partition. Expired entries are removed on access. + pub(crate) async fn cached_response(&self, logical_key: &str) -> Option { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled { + return None; + } + + let private_key = Self::cache_key( + logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ); + if let Some(entry) = cache.entries.get(&private_key) { + if entry.expires_at > now && entry.scope == CacheScope::Private { + return Some(entry.value.clone()); + } + } + cache.entries.remove(&private_key); + + let public_key = Self::cache_key(logical_key, CachePartition::Public); + if let Some(entry) = cache.entries.get(&public_key) { + if entry.expires_at > now && entry.scope == CacheScope::Public { + return Some(entry.value.clone()); + } + } + cache.entries.remove(&public_key); + None + } + + /// Stores a response when the configured effective TTL is positive. + /// + /// Missing `cacheScope` is treated as private. This is deliberately more + /// conservative than the model's backwards-compatible wire default and + /// prevents an older or malformed server response from becoming shareable. + pub(crate) async fn cache_response( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + ) { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled { + return; + } + + let requested_ttl = ttl_ms + .map(Duration::from_millis) + .unwrap_or(cache.config.default_ttl); + let ttl = requested_ttl.min(cache.config.max_ttl); + if ttl.is_zero() { + return; + } + let Some(expires_at) = now.checked_add(ttl) else { + return; + }; + let scope = cache_scope.unwrap_or(CacheScope::Private); + let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config); + let opposite_key = match scope { + CacheScope::Public => Self::cache_key( + &logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ), + CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public), + }; + + cache.entries.retain(|_, entry| entry.expires_at > now); + cache.entries.remove(&opposite_key); + cache.entries.insert( + target_key, + CachedPeerResponse { + value, + expires_at, + scope, + }, + ); + } + + pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { + self.response_cache + .write() + .await + .entries + .retain(|key, _| !key.logical_key.starts_with(prefix)); + } + + pub(crate) async fn invalidate_cached_response(&self, logical_key: &str) { + self.response_cache + .write() + .await + .entries + .retain(|key, _| key.logical_key != logical_key); + } +} + +impl Peer { + /// Replaces the response-cache configuration. + /// + /// Changing the private partition invalidates private entries from the old + /// authorization context. Disabling the cache clears every entry. + pub async fn set_response_cache_config(&self, config: ClientCacheConfig) { + let mut cache = self.response_cache.write().await; + let partition_changed = cache.config.private_partition != config.private_partition; + cache.config = config; + if !cache.config.enabled { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + } + + /// Returns a snapshot of the active response-cache configuration. + pub async fn response_cache_config(&self) -> ClientCacheConfig { + self.response_cache.read().await.config.clone() + } + + /// Clears every cached client response without changing the configuration. + pub async fn clear_response_cache(&self) { + self.response_cache.write().await.entries.clear(); + } +} diff --git a/docs/CLIENT_CACHING.md b/docs/CLIENT_CACHING.md new file mode 100644 index 00000000..610878e7 --- /dev/null +++ b/docs/CLIENT_CACHING.md @@ -0,0 +1,41 @@ +# Client response caching + +The Rust SDK client honours SEP-2549 caching hints for `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and +`resources/read`. + +Each client connection owns an in-memory cache. A response is served from the +cache only while its effective TTL is fresh. The default TTL for responses that +omit `ttlMs` is zero, and every TTL is capped at 24 hours. + +```rust,ignore +use std::time::Duration; +use rmcp::{ClientCacheConfig, ServiceExt}; + +let client = handler.serve(transport).await?; +client + .set_response_cache_config( + ClientCacheConfig::default() + .with_default_ttl(Duration::from_secs(30)) + .with_private_partition(user_id), + ) + .await; +``` + +`private_partition` is an opaque stable identity for the current authorization +context. A normal single-principal client may leave it unset because its cache +is not shared with another client. A gateway, or any client that changes the +principal associated with an existing connection, should set it and update it +when the authorization context changes. Updating the partition discards old +private entries while preserving public entries. + +Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or +`clear_response_cache()` to clear held responses without changing the policy. + +Cache keys include the method and result-affecting parameters: the cursor for +paginated list methods and the URI for resource reads. MRTR retries containing +`inputResponses` or `requestState` are never cached. List-change notifications +invalidate every cached page for the corresponding method, while resource +update notifications invalidate only the matching URI. When a cursor request +fails, all cached pages for that list method are discarded so the next walk can +restart from the beginning. diff --git a/issue-974-validation-result.txt b/issue-974-validation-result.txt new file mode 100644 index 00000000..cfee0e37 --- /dev/null +++ b/issue-974-validation-result.txt @@ -0,0 +1,2 @@ +validation=failed +Inspect the GitHub Actions job logs for the first failing step. From e31eeeb6fc4ed79d77d90a449beaf3c88db8e53b Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:42:31 +0100 Subject: [PATCH 06/48] ci: diagnose issue 974 validation failures --- .github/workflows/harden-issue-974.yml | 107 +++++++++---------------- 1 file changed, 36 insertions(+), 71 deletions(-) diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml index 44cd2787..753c9e4f 100644 --- a/.github/workflows/harden-issue-974.yml +++ b/.github/workflows/harden-issue-974.yml @@ -1,4 +1,4 @@ -name: Harden and validate issue 974 +name: Diagnose issue 974 validation on: push: @@ -11,7 +11,7 @@ permissions: contents: write jobs: - harden-and-validate: + diagnose: runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -29,85 +29,50 @@ jobs: - name: Install nightly rustfmt run: rustup toolchain install nightly --component rustfmt - - name: Apply hardening changes - env: - SCRIPT_GZ_B64: >- - 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 + - name: Run and record repository checks + shell: bash run: | - printf '%s' "$SCRIPT_GZ_B64" | base64 --decode | gzip --decompress > /tmp/apply_issue_974_hardening.py - python /tmp/apply_issue_974_hardening.py + set +e + : > issue-974-validation-result.txt + failed=0 + + run_check() { + name="$1" + shift + log="/tmp/${name}.log" + echo "===== ${name} =====" >> issue-974-validation-result.txt + "$@" >"$log" 2>&1 + status=$? + echo "status=${status}" >> issue-974-validation-result.txt + if [ "$status" -ne 0 ]; then + failed=1 + echo "--- last 200 log lines ---" >> issue-974-validation-result.txt + tail -n 200 "$log" >> issue-974-validation-result.txt + fi + echo >> issue-974-validation-result.txt + } + + run_check nightly_fmt cargo +nightly fmt --all -- --check + run_check whitespace git diff --check + run_check workspace_check cargo check --workspace --all-targets --all-features + run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings + run_check all_feature_tests cargo test --all-features - - name: Format with repository toolchain - run: cargo +nightly fmt --all - - - name: Verify formatting and whitespace - run: | - cargo +nightly fmt --all -- --check - git diff --check - - - name: Check workspace - run: cargo check --workspace --all-targets --all-features - - - name: Run workspace Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run all-feature tests - run: cargo test --all-features - - - name: Run rmcp tests without local feature - run: | FEATURES=$(cargo metadata --no-deps --format-version 1 \ | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ | select(startswith("__") | not) \ | select(. != "local")] | join(",")') - cargo test -p rmcp --features "$FEATURES" + run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" + run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + run_check conformance_build cargo build -p mcp-conformance - - name: Build documentation - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc -p rmcp --all-features --no-deps - - - name: Build conformance binaries - run: cargo build -p mcp-conformance - - - name: Run spelling check - uses: crate-ci/typos@master - - - name: Record success - if: success() - run: | - cat > issue-974-validation-result.txt <<'EOF' - nightly_fmt=success - whitespace=success - workspace_check=success - workspace_clippy=success - all_feature_tests=success - rmcp_no_local_tests=success - docs=success - conformance_build=success - typos=success - EOF - - - name: Record failure - if: failure() - run: | - cat > issue-974-validation-result.txt <<'EOF' - validation=failed - Inspect the GitHub Actions job logs for the first failing step. - EOF + echo "overall_failed=${failed}" >> issue-974-validation-result.txt - - name: Commit implementation and validation result + - name: Commit diagnostic report if: always() run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - crates/rmcp/src/service.rs \ - crates/rmcp/src/service/client.rs \ - crates/rmcp/src/service/client/cache.rs \ - crates/rmcp/src/handler/client.rs \ - crates/rmcp/CHANGELOG.md \ - docs/CLIENT_CACHING.md \ - issue-974-validation-result.txt - git commit -m "feat(client): complete SEP-2549 response caching" || true + git add issue-974-validation-result.txt + git commit -m "test: record issue 974 validation diagnostics" || true git push origin HEAD:feat/issue-974-client-response-cache From 1df6fd90df96041a1df2a369e6de2d6391c3e56d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:47:23 +0000 Subject: [PATCH 07/48] test: record issue 974 validation diagnostics --- issue-974-validation-result.txt | 287 +++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 2 deletions(-) diff --git a/issue-974-validation-result.txt b/issue-974-validation-result.txt index cfee0e37..4a9eb19a 100644 --- a/issue-974-validation-result.txt +++ b/issue-974-validation-result.txt @@ -1,2 +1,285 @@ -validation=failed -Inspect the GitHub Actions job logs for the first failing step. +===== nightly_fmt ===== +status=0 + +===== whitespace ===== +status=0 + +===== workspace_check ===== +status=101 +--- last 200 log lines --- + | +230 | #[task_handler] + | ^^^^^^^^^^^^^^^ `dyn Fn(PromptContext<'a, Counter>) -> Pin>` cannot be sent between threads safely + | + = help: the trait `std::marker::Send` is not implemented for `dyn Fn(PromptContext<'a, Counter>) -> Pin>` + = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` +note: required because it appears within the type `PromptRoute` + --> crates/rmcp/src/handler/server/router/prompt.rs:10:12 + | + 10 | pub struct PromptRoute { + | ^^^^^^^^^^^ + = note: required because it appears within the type `(Cow<'static, str>, PromptRoute)` + = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, PromptRoute)>` to implement `std::marker::Send` +note: required because it appears within the type `hashbrown::map::HashMap, PromptRoute, RandomState>` + --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 +note: required because it appears within the type `HashMap, PromptRoute>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 +note: required because it appears within the type `PromptRouter` + --> crates/rmcp/src/handler/server/router/prompt.rs:110:12 + | +110 | pub struct PromptRouter { + | ^^^^^^^^^^^^ +note: required because it appears within the type `Counter` + --> examples/servers/src/common/counter.rs:77:12 + | + 77 | pub struct Counter { + | ^^^^^^^ +note: required because it's used within this `async` block + --> examples/servers/src/common/counter.rs:230:1 + | +230 | #[task_handler] + | ^^^^^^^^^^^^^^^ + = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` + = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-13449520427118935295.txt' + = note: consider using `--verbose` to print the full type name to the console + = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `dyn futures::Future>` cannot be sent between threads safely + --> examples/servers/src/common/counter.rs:230:1 + | +230 | #[task_handler] + | ^^^^^^^^^^^^^^^ `dyn futures::Future>` cannot be sent between threads safely + | + = help: the trait `std::marker::Send` is not implemented for `dyn futures::Future>` + = note: required for `std::ptr::Unique>>` to implement `std::marker::Send` +note: required because it appears within the type `Box>>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/alloc/src/boxed.rs:234:11 +note: required because it appears within the type `Pin>>>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/core/src/pin.rs:1092:11 +note: required because it's used within this `async` fn body + --> crates/rmcp/src/handler/server/router/tool.rs:563:67 + | +563 | ) -> Result { + |  ___________________________________________________________________^ +564 | | let name = context.name(); +565 | | if self.disabled.contains(name) { +566 | | return Err(crate::ErrorData::invalid_params("tool not found", None)); +... | +578 | | Ok(result) +579 | | } + | |_____^ +note: required because it's used within this `async` fn body + --> examples/servers/src/common/counter.rs:228:1 + | +228 | #[tool_handler(meta = Meta(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +note: required because it's used within this `async` block + --> examples/servers/src/common/counter.rs:230:1 + | +230 | #[task_handler] + | ^^^^^^^^^^^^^^^ + = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` + = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-6481740063410040282.txt' + = note: consider using `--verbose` to print the full type name to the console + = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be shared between threads safely + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be shared between threads safely + | + = help: the trait `Sync` is not implemented for `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` + = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` +note: required because it appears within the type `ToolRoute` + --> crates/rmcp/src/handler/server/router/tool.rs:159:12 + | +159 | pub struct ToolRoute { + | ^^^^^^^^^ + = note: required because it appears within the type `(Cow<'static, str>, ToolRoute)` + = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, ToolRoute)>` to implement `std::marker::Send` +note: required because it appears within the type `hashbrown::map::HashMap, ToolRoute, RandomState>` + --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 +note: required because it appears within the type `HashMap, ToolRoute>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 +note: required because it appears within the type `ToolRouter` + --> crates/rmcp/src/handler/server/router/tool.rs:325:12 + | +325 | pub struct ToolRouter { + | ^^^^^^^^^^ +note: required because it appears within the type `TaskDemo` + --> examples/servers/src/common/task_demo.rs:41:12 + | + 41 | pub struct TaskDemo { + | ^^^^^^^^ +note: required because it's used within this `async` block + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ + = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` + = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-4286591992877008387.txt' + = note: consider using `--verbose` to print the full type name to the console + = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be sent between threads safely + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be sent between threads safely + | + = help: the trait `std::marker::Send` is not implemented for `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` + = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` +note: required because it appears within the type `ToolRoute` + --> crates/rmcp/src/handler/server/router/tool.rs:159:12 + | +159 | pub struct ToolRoute { + | ^^^^^^^^^ + = note: required because it appears within the type `(Cow<'static, str>, ToolRoute)` + = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, ToolRoute)>` to implement `std::marker::Send` +note: required because it appears within the type `hashbrown::map::HashMap, ToolRoute, RandomState>` + --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 +note: required because it appears within the type `HashMap, ToolRoute>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 +note: required because it appears within the type `ToolRouter` + --> crates/rmcp/src/handler/server/router/tool.rs:325:12 + | +325 | pub struct ToolRouter { + | ^^^^^^^^^^ +note: required because it appears within the type `TaskDemo` + --> examples/servers/src/common/task_demo.rs:41:12 + | + 41 | pub struct TaskDemo { + | ^^^^^^^^ +note: required because it's used within this `async` block + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ + = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` + = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-4286591992877008387.txt' + = note: consider using `--verbose` to print the full type name to the console + = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `dyn futures::Future>` cannot be sent between threads safely + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ `dyn futures::Future>` cannot be sent between threads safely + | + = help: the trait `std::marker::Send` is not implemented for `dyn futures::Future>` + = note: required for `std::ptr::Unique>>` to implement `std::marker::Send` +note: required because it appears within the type `Box>>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/alloc/src/boxed.rs:234:11 +note: required because it appears within the type `Pin>>>` + --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/core/src/pin.rs:1092:11 +note: required because it's used within this `async` fn body + --> crates/rmcp/src/handler/server/router/tool.rs:563:67 + | +563 | ) -> Result { + |  ___________________________________________________________________^ +564 | | let name = context.name(); +565 | | if self.disabled.contains(name) { +566 | | return Err(crate::ErrorData::invalid_params("tool not found", None)); +... | +578 | | Ok(result) +579 | | } + | |_____^ +note: required because it's used within this `async` fn body + --> examples/servers/src/common/task_demo.rs:91:1 + | + 91 | #[tool_handler] + | ^^^^^^^^^^^^^^^ +note: required because it's used within this `async` block + --> examples/servers/src/common/task_demo.rs:92:1 + | + 92 | #[task_handler] + | ^^^^^^^^^^^^^^^ + = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` + = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-6481740063410040282.txt' + = note: consider using `--verbose` to print the full type name to the console + = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) + +For more information about this error, try `rustc --explain E0277`. +error: could not compile `mcp-server-examples` (example "servers_calculator_stdio") due to 8 previous errors +warning: build failed, waiting for other jobs to finish... +warning: `mcp-conformance` (bin "conformance-client" test) generated 7 warnings +Some errors have detailed explanations: E0277, E0432. +For more information about an error, try `rustc --explain E0277`. +error: could not compile `mcp-server-examples` (example "servers_complex_auth_streamhttp") due to 9 previous errors + +===== workspace_clippy ===== +status=0 + +===== all_feature_tests ===== +status=0 + +===== rmcp_no_local_tests ===== +status=101 +--- last 200 log lines --- + Compiling libc v0.2.186 + Compiling num-traits v0.2.19 + Compiling sync_wrapper v1.0.2 + Compiling rmcp v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp) + Compiling rstest_macros v0.26.1 + Compiling axum-core v0.5.6 + Compiling chrono v0.4.45 + Compiling errno v0.3.14 + Compiling parking_lot_core v0.9.12 + Compiling parking_lot v0.12.5 + Compiling signal-hook-registry v1.4.8 + Compiling socket2 v0.6.4 + Compiling mio v1.2.1 + Compiling tokio v1.52.3 + Compiling schemars v1.2.1 + Compiling rmcp-macros v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp-macros) + Compiling rstest v0.26.1 + Compiling url v2.5.8 + Compiling hyper v1.10.1 + Compiling tokio-util v0.7.18 + Compiling tower v0.5.3 + Compiling hyper-util v0.1.20 + Compiling axum v0.8.9 +warning: struct `Sum` is never constructed + --> crates/rmcp/tests/test_prompt_routers.rs:24:8 + | +24 | struct Sum { + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `rmcp` (test "test_prompt_routers") generated 1 warning +warning: struct `Sum` is never constructed + --> crates/rmcp/tests/test_tool_routers.rs:25:8 + | +25 | struct Sum { + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `rmcp` (test "test_tool_routers") generated 1 warning +error[E0425]: cannot find function `stdio` in module `rmcp::transport` + --> crates/rmcp/tests/test_stdio_response_concurrency.rs:106:61 + | +106 | let server = LargeResponseServer.serve(rmcp::transport::stdio()).await?; + | ^^^^^ not found in `rmcp::transport` + | +note: found an item that was configured out + --> crates/rmcp/src/transport.rs:94:13 + | + 93 | #[cfg(feature = "transport-io")] + | ------------------------ the item is gated behind the `transport-io` feature + 94 | pub use io::stdio; + | ^^^^^ + +For more information about this error, try `rustc --explain E0425`. +error: could not compile `rmcp` (test "test_stdio_response_concurrency") due to 1 previous error +warning: build failed, waiting for other jobs to finish... + +===== docs ===== +status=0 + +===== conformance_build ===== +status=0 + +overall_failed=1 From d5ca1d4d364505b3104507cd6e8963fd0d6858e5 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:48:51 +0100 Subject: [PATCH 08/48] ci: capture detailed issue 974 validation logs --- .github/workflows/harden-issue-974.yml | 47 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml index 753c9e4f..360cfb82 100644 --- a/.github/workflows/harden-issue-974.yml +++ b/.github/workflows/harden-issue-974.yml @@ -16,21 +16,14 @@ jobs: timeout-minutes: 90 steps: - name: Check out issue branch - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: feat/issue-974-client-response-cache fetch-depth: 0 - - name: Install stable Rust with Clippy - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Run and record repository checks + - name: Run repository checks and capture logs shell: bash + continue-on-error: true run: | set +e : > issue-974-validation-result.txt @@ -46,23 +39,39 @@ jobs: echo "status=${status}" >> issue-974-validation-result.txt if [ "$status" -ne 0 ]; then failed=1 - echo "--- last 200 log lines ---" >> issue-974-validation-result.txt - tail -n 200 "$log" >> issue-974-validation-result.txt + echo "--- last 300 log lines ---" >> issue-974-validation-result.txt + tail -n 300 "$log" >> issue-974-validation-result.txt fi echo >> issue-974-validation-result.txt } + run_check install_stable rustup toolchain install stable --component clippy + run_check install_nightly rustup toolchain install nightly --component rustfmt run_check nightly_fmt cargo +nightly fmt --all -- --check run_check whitespace git diff --check - run_check workspace_check cargo check --workspace --all-targets --all-features - run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings - run_check all_feature_tests cargo test --all-features + run_check rmcp_check cargo check -p rmcp --all-targets --all-features + run_check rmcp_clippy cargo clippy -p rmcp --all-targets --all-features -- -D warnings + run_check rmcp_tests cargo test -p rmcp --all-features - FEATURES=$(cargo metadata --no-deps --format-version 1 \ + FEATURES=$(cargo metadata --no-deps --format-version 1 2>/tmp/metadata.log \ | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ | select(startswith("__") | not) \ | select(. != "local")] | join(",")') - run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" + metadata_status=$? + echo "===== metadata =====" >> issue-974-validation-result.txt + echo "status=${metadata_status}" >> issue-974-validation-result.txt + if [ "$metadata_status" -ne 0 ]; then + failed=1 + cat /tmp/metadata.log >> issue-974-validation-result.txt + fi + echo >> issue-974-validation-result.txt + if [ "$metadata_status" -eq 0 ]; then + run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" + fi + + run_check workspace_check cargo check --workspace --all-targets --all-features + run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings + run_check all_feature_tests cargo test --all-features run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps run_check conformance_build cargo build -p mcp-conformance @@ -70,9 +79,13 @@ jobs: - name: Commit diagnostic report if: always() + shell: bash run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if [ ! -f issue-974-validation-result.txt ]; then + printf 'validation step did not create a report\n' > issue-974-validation-result.txt + fi git add issue-974-validation-result.txt git commit -m "test: record issue 974 validation diagnostics" || true git push origin HEAD:feat/issue-974-client-response-cache From 66589151a546cbec77b42b03ac990e006927f3e6 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:52:04 +0100 Subject: [PATCH 09/48] fix(client): harden response cache invalidation --- crates/rmcp/src/service/client/cache.rs | 103 ++++++++++++++++++++---- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/crates/rmcp/src/service/client/cache.rs b/crates/rmcp/src/service/client/cache.rs index 4c70c338..c91e8588 100644 --- a/crates/rmcp/src/service/client/cache.rs +++ b/crates/rmcp/src/service/client/cache.rs @@ -37,6 +37,10 @@ pub struct ClientCacheConfig { /// existing connection should set this value and update it whenever the /// authorization context changes. pub private_partition: Option, + /// Maximum number of responses retained by the in-memory cache. + /// + /// A value of zero disables the size limit. + pub max_entries: usize, } impl Default for ClientCacheConfig { @@ -46,6 +50,7 @@ impl Default for ClientCacheConfig { default_ttl: Duration::ZERO, max_ttl: MAX_CLIENT_CACHE_TTL, private_partition: None, + max_entries: 512, } } } @@ -76,6 +81,12 @@ impl ClientCacheConfig { self.private_partition = Some(partition.into()); self } + + /// Sets the maximum number of retained responses. + pub fn with_max_entries(mut self, max_entries: usize) -> Self { + self.max_entries = max_entries; + self + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -94,13 +105,18 @@ struct CacheKey { struct CachedPeerResponse { value: T, expires_at: Instant, + inserted_at: Instant, scope: CacheScope, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CacheGeneration(u64); + #[derive(Debug)] pub(crate) struct PeerResponseCacheState { entries: HashMap>, config: ClientCacheConfig, + generation: u64, } impl Default for PeerResponseCacheState { @@ -108,6 +124,23 @@ impl Default for PeerResponseCacheState { Self { entries: HashMap::new(), config: ClientCacheConfig::default(), + generation: 0, + } + } +} + +impl PeerResponseCacheState { + fn trim_to_limit(&mut self) { + while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { + let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + self.entries.remove(&oldest_key); } } } @@ -138,6 +171,16 @@ impl Peer { Self::cache_key(logical_key, partition) } + /// Captures the cache generation before a request crosses the transport. + /// + /// Any configuration change, explicit clear, or notification invalidation + /// advances the generation. A response from an older generation is not + /// written back, preventing an in-flight stale response from undoing an + /// invalidation. + pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration { + CacheGeneration(self.response_cache.read().await.generation) + } + /// Returns a fresh cached response, preferring the current private partition /// before the public partition. Expired entries are removed on access. pub(crate) async fn cached_response(&self, logical_key: &str) -> Option { @@ -151,18 +194,20 @@ impl Peer { logical_key, CachePartition::Private(Self::private_partition(&cache.config)), ); - if let Some(entry) = cache.entries.get(&private_key) { - if entry.expires_at > now && entry.scope == CacheScope::Private { - return Some(entry.value.clone()); - } + if let Some(entry) = cache.entries.get(&private_key) + && entry.expires_at > now + && entry.scope == CacheScope::Private + { + return Some(entry.value.clone()); } cache.entries.remove(&private_key); let public_key = Self::cache_key(logical_key, CachePartition::Public); - if let Some(entry) = cache.entries.get(&public_key) { - if entry.expires_at > now && entry.scope == CacheScope::Public { - return Some(entry.value.clone()); - } + if let Some(entry) = cache.entries.get(&public_key) + && entry.expires_at > now + && entry.scope == CacheScope::Public + { + return Some(entry.value.clone()); } cache.entries.remove(&public_key); None @@ -179,10 +224,11 @@ impl Peer { value: R::PeerResp, ttl_ms: Option, cache_scope: Option, + generation: CacheGeneration, ) { let now = Instant::now(); let mut cache = self.response_cache.write().await; - if !cache.config.enabled { + if !cache.config.enabled || generation.0 != cache.generation { return; } @@ -208,28 +254,42 @@ impl Peer { cache.entries.retain(|_, entry| entry.expires_at > now); cache.entries.remove(&opposite_key); + + if cache.config.max_entries > 0 + && !cache.entries.contains_key(&target_key) + && cache.entries.len() >= cache.config.max_entries + && let Some(oldest_key) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + { + cache.entries.remove(&oldest_key); + } + cache.entries.insert( target_key, CachedPeerResponse { value, expires_at, + inserted_at: now, scope, }, ); } pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { - self.response_cache - .write() - .await + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache .entries .retain(|key, _| !key.logical_key.starts_with(prefix)); } pub(crate) async fn invalidate_cached_response(&self, logical_key: &str) { - self.response_cache - .write() - .await + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache .entries .retain(|key, _| key.logical_key != logical_key); } @@ -239,11 +299,17 @@ impl Peer { /// Replaces the response-cache configuration. /// /// Changing the private partition invalidates private entries from the old - /// authorization context. Disabling the cache clears every entry. + /// authorization context. Disabling the cache clears every entry. Any + /// configuration change also suppresses writes from requests that were + /// already in flight under the previous configuration. pub async fn set_response_cache_config(&self, config: ClientCacheConfig) { let mut cache = self.response_cache.write().await; + let config_changed = cache.config != config; let partition_changed = cache.config.private_partition != config.private_partition; cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } if !cache.config.enabled { cache.entries.clear(); } else if partition_changed { @@ -251,6 +317,7 @@ impl Peer { .entries .retain(|_, entry| entry.scope == CacheScope::Public); } + cache.trim_to_limit(); } /// Returns a snapshot of the active response-cache configuration. @@ -260,6 +327,8 @@ impl Peer { /// Clears every cached client response without changing the configuration. pub async fn clear_response_cache(&self) { - self.response_cache.write().await.entries.clear(); + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache.entries.clear(); } } From 6306ef206b0eb9a139b74458c51132d0239b092a Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:53:09 +0100 Subject: [PATCH 10/48] chore: add issue 974 finalization script --- scripts/finalize_issue_974.py | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 scripts/finalize_issue_974.py diff --git a/scripts/finalize_issue_974.py b/scripts/finalize_issue_974.py new file mode 100644 index 00000000..a8ca7a91 --- /dev/null +++ b/scripts/finalize_issue_974.py @@ -0,0 +1,102 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + file = ROOT / path + text = file.read_text() + count = text.count(old) + if count != 1: + raise RuntimeError(f"{path}: expected one match, found {count}") + file.write_text(text.replace(old, new, 1)) + + +# Keep the old direct seeding helper for unit tests while production writes +# require a generation captured before the transport request. +replace_once( + "crates/rmcp/src/service/client/cache.rs", + """ pub(crate) async fn cache_response(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n ) {\n""", + """ pub(crate) async fn cache_response_with_generation(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n ) {\n""", +) + +replace_once( + "crates/rmcp/src/service/client/cache.rs", + """ pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {\n""", + """ #[cfg(test)]\n pub(crate) async fn cache_response(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n ) {\n let generation = self.capture_response_cache_generation().await;\n self.cache_response_with_generation(\n logical_key,\n value,\n ttl_ms,\n cache_scope,\n generation,\n )\n .await;\n }\n\n pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {\n""", +) + +replace_once( + "crates/rmcp/src/service/client.rs", + """pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL};\n""", + """pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL};\nuse cache::CacheGeneration;\n""", +) + +replace_once( + "crates/rmcp/src/service/client.rs", + """ async fn cache_result(\n &self,\n cache_key: Option,\n ttl_ms: Option,\n cache_scope: Option,\n result: ServerResult,\n ) {\n let Some(cache_key) = cache_key else {\n return;\n };\n self.cache_response(cache_key, result, ttl_ms, cache_scope)\n .await;\n }\n""", + """ async fn cache_result(\n &self,\n cache_key: Option,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n result: ServerResult,\n ) {\n let Some(cache_key) = cache_key else {\n return;\n };\n self.cache_response_with_generation(\n cache_key,\n result,\n ttl_ms,\n cache_scope,\n generation,\n )\n .await;\n }\n""", +) + +# resources/read: capture after a miss and before crossing the transport. +replace_once( + "crates/rmcp/src/service/client.rs", + """ let result = self\n .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest {\n""", + """ let generation = self.capture_response_cache_generation().await;\n let result = self\n .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest {\n""", +) +replace_once( + "crates/rmcp/src/service/client.rs", + """ result.cache_scope,\n ServerResult::ReadResourceResult(result.clone()),\n""", + """ result.cache_scope,\n generation,\n ServerResult::ReadResourceResult(result.clone()),\n""", +) + +# The four list methods all use the same miss -> capture -> request pattern. +for marker in [ + "let uses_cursor = request_uses_cursor(¶ms);", +]: + text = (ROOT / "crates/rmcp/src/service/client.rs").read_text() + expected = 4 + actual = text.count(marker) + if actual != expected: + raise RuntimeError(f"client.rs: expected {expected} cursor markers, found {actual}") + text = text.replace( + marker, + "let generation = self.capture_response_cache_generation().await;\n " + marker, + ) + (ROOT / "crates/rmcp/src/service/client.rs").write_text(text) + +# Add the generation argument to the four list cache writes. +client = ROOT / "crates/rmcp/src/service/client.rs" +text = client.read_text() +for variant in [ + "ListPromptsResult", + "ListResourcesResult", + "ListResourceTemplatesResult", + "ListToolsResult", +]: + old = f""" result.cache_scope,\n ServerResult::{variant}(result.clone()),\n""" + new = f""" result.cache_scope,\n generation,\n ServerResult::{variant}(result.clone()),\n""" + count = text.count(old) + if count != 1: + raise RuntimeError(f"client.rs: expected one {variant} cache write, found {count}") + text = text.replace(old, new, 1) +client.write_text(text) + +# Add regression tests for stale in-flight writes and the entry bound. +replace_once( + "crates/rmcp/src/service/client.rs", + """ #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {\n""", + """ #[tokio::test]\n async fn invalidation_suppresses_an_in_flight_cache_write() {\n let peer = disconnected_peer();\n let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None);\n let generation = peer.capture_response_cache_generation().await;\n peer.invalidate_tool_cache().await;\n peer.cache_response_with_generation(\n key.clone(),\n ServerResult::ListToolsResult(tools_result(\n Some(5_000),\n Some(CacheScope::Private),\n )),\n Some(5_000),\n Some(CacheScope::Private),\n generation,\n )\n .await;\n\n assert!(peer.cached_response(&key).await.is_none());\n }\n\n #[tokio::test]\n async fn entry_limit_evicts_the_oldest_response() {\n let peer = disconnected_peer();\n peer.set_response_cache_config(\n ClientCacheConfig::default().with_max_entries(1),\n )\n .await;\n let first = resource_read_cache_key_for_uri(\"file:///first\");\n let second = resource_read_cache_key_for_uri(\"file:///second\");\n peer.cache_response(\n first.clone(),\n ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())),\n Some(5_000),\n Some(CacheScope::Private),\n )\n .await;\n tokio::time::sleep(Duration::from_millis(1)).await;\n peer.cache_response(\n second.clone(),\n ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())),\n Some(5_000),\n Some(CacheScope::Private),\n )\n .await;\n\n assert!(peer.cached_response(&first).await.is_none());\n assert!(peer.cached_response(&second).await.is_some());\n }\n\n #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {\n""", +) + +# Document the final two guarantees. +docs = ROOT / "docs/CLIENT_CACHING.md" +text = docs.read_text() +text = text.replace( + "Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or\n`clear_response_cache()` to clear held responses without changing the policy.\n", + "Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or\n`clear_response_cache()` to clear held responses without changing the policy.\n`with_max_entries()` bounds the in-memory store; the default is 512 entries and\na value of zero removes the limit.\n", +) +text += "\nCache invalidation advances an internal generation. Responses from requests that\nwere already in flight before an invalidation or authorization-context change are\nnot written back into the cache.\n" +docs.write_text(text) + +print("issue 974 finalization edits applied") From 844ef28000548d03acbab1f6b1e59672cfcb0a5c Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:54:23 +0100 Subject: [PATCH 11/48] ci: finalize and validate issue 974 cache --- .github/workflows/harden-issue-974.yml | 52 +++++++++++++++----------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml index 360cfb82..a18e150b 100644 --- a/.github/workflows/harden-issue-974.yml +++ b/.github/workflows/harden-issue-974.yml @@ -1,4 +1,4 @@ -name: Diagnose issue 974 validation +name: Finalize issue 974 validation on: push: @@ -11,7 +11,7 @@ permissions: contents: write jobs: - diagnose: + finalize-and-validate: runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -21,6 +21,28 @@ jobs: ref: feat/issue-974-client-response-cache fetch-depth: 0 + - name: Install toolchains + run: | + rustup toolchain install stable --component clippy + rustup toolchain install nightly --component rustfmt + + - name: Apply final hardening edits + run: | + python scripts/finalize_issue_974.py + cargo +nightly fmt --all + git diff --check + + - name: Commit hardened implementation + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + crates/rmcp/src/service/client.rs \ + crates/rmcp/src/service/client/cache.rs \ + docs/CLIENT_CACHING.md + git commit -m "fix(client): prevent stale cache writes" || true + git push origin HEAD:feat/issue-974-client-response-cache + - name: Run repository checks and capture logs shell: bash continue-on-error: true @@ -45,39 +67,25 @@ jobs: echo >> issue-974-validation-result.txt } - run_check install_stable rustup toolchain install stable --component clippy - run_check install_nightly rustup toolchain install nightly --component rustfmt run_check nightly_fmt cargo +nightly fmt --all -- --check run_check whitespace git diff --check run_check rmcp_check cargo check -p rmcp --all-targets --all-features - run_check rmcp_clippy cargo clippy -p rmcp --all-targets --all-features -- -D warnings - run_check rmcp_tests cargo test -p rmcp --all-features + run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings + run_check all_feature_tests cargo test --all-features - FEATURES=$(cargo metadata --no-deps --format-version 1 2>/tmp/metadata.log \ + FEATURES=$(cargo metadata --no-deps --format-version 1 \ | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ | select(startswith("__") | not) \ | select(. != "local")] | join(",")') - metadata_status=$? - echo "===== metadata =====" >> issue-974-validation-result.txt - echo "status=${metadata_status}" >> issue-974-validation-result.txt - if [ "$metadata_status" -ne 0 ]; then - failed=1 - cat /tmp/metadata.log >> issue-974-validation-result.txt - fi - echo >> issue-974-validation-result.txt - if [ "$metadata_status" -eq 0 ]; then - run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" - fi + echo "FEATURES=${FEATURES}" >> issue-974-validation-result.txt + run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" - run_check workspace_check cargo check --workspace --all-targets --all-features - run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings - run_check all_feature_tests cargo test --all-features run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps run_check conformance_build cargo build -p mcp-conformance echo "overall_failed=${failed}" >> issue-974-validation-result.txt - - name: Commit diagnostic report + - name: Commit validation report if: always() shell: bash run: | From 51629ae9dc687a9b574a8b2c53015d754615cca2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:54:48 +0000 Subject: [PATCH 12/48] fix(client): prevent stale cache writes --- crates/rmcp/src/service/client.rs | 59 ++++++++++++++++++++++++- crates/rmcp/src/service/client/cache.rs | 15 ++++++- docs/CLIENT_CACHING.md | 6 +++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index caffd3a6..6722b7ea 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -4,6 +4,7 @@ pub(super) mod cache; use std::{borrow::Cow, sync::Arc, time::Duration}; +use cache::CacheGeneration; pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL}; use thiserror::Error; @@ -404,12 +405,13 @@ impl Peer { cache_key: Option, ttl_ms: Option, cache_scope: Option, + generation: CacheGeneration, result: ServerResult, ) { let Some(cache_key) = cache_key else { return; }; - self.cache_response(cache_key, result, ttl_ms, cache_scope) + self.cache_response_with_generation(cache_key, result, ttl_ms, cache_scope, generation) .await; } @@ -492,6 +494,7 @@ impl Peer { return Ok(ReadResourceResponse::Complete(result)); } + let generation = self.capture_response_cache_generation().await; let result = self .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { method: Default::default(), @@ -505,6 +508,7 @@ impl Peer { cache_key, result.ttl_ms, result.cache_scope, + generation, ServerResult::ReadResourceResult(result.clone()), ) .await; @@ -540,6 +544,7 @@ impl Peer { { return Ok(result); } + let generation = self.capture_response_cache_generation().await; let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { @@ -557,6 +562,7 @@ impl Peer { Some(cache_key), result.ttl_ms, result.cache_scope, + generation, ServerResult::ListPromptsResult(result.clone()), ) .await; @@ -576,6 +582,7 @@ impl Peer { { return Ok(result); } + let generation = self.capture_response_cache_generation().await; let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { @@ -594,6 +601,7 @@ impl Peer { Some(cache_key), result.ttl_ms, result.cache_scope, + generation, ServerResult::ListResourcesResult(result.clone()), ) .await; @@ -613,6 +621,7 @@ impl Peer { { return Ok(result); } + let generation = self.capture_response_cache_generation().await; let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListResourceTemplatesRequest( @@ -633,6 +642,7 @@ impl Peer { Some(cache_key), result.ttl_ms, result.cache_scope, + generation, ServerResult::ListResourceTemplatesResult(result.clone()), ) .await; @@ -661,6 +671,7 @@ impl Peer { { return Ok(result); } + let generation = self.capture_response_cache_generation().await; let uses_cursor = request_uses_cursor(¶ms); let result = self .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { @@ -678,6 +689,7 @@ impl Peer { Some(cache_key), result.ttl_ms, result.cache_scope, + generation, ServerResult::ListToolsResult(result.clone()), ) .await; @@ -1450,6 +1462,51 @@ mod tests { assert!(peer.cached_response(&prompt_key).await.is_some()); } + #[tokio::test] + async fn invalidation_suppresses_an_in_flight_cache_write() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + let generation = peer.capture_response_cache_generation().await; + peer.invalidate_tool_cache().await; + peer.cache_response_with_generation( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), + Some(5_000), + Some(CacheScope::Private), + generation, + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn entry_limit_evicts_the_oldest_response() { + let peer = disconnected_peer(); + peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) + .await; + let first = resource_read_cache_key_for_uri("file:///first"); + let second = resource_read_cache_key_for_uri("file:///second"); + peer.cache_response( + first.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + tokio::time::sleep(Duration::from_millis(1)).await; + peer.cache_response( + second.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + + assert!(peer.cached_response(&first).await.is_none()); + assert!(peer.cached_response(&second).await.is_some()); + } + #[test] fn mrtr_retry_parameters_are_not_cacheable() { let params = ReadResourceRequestParams::new("file:///example.txt") diff --git a/crates/rmcp/src/service/client/cache.rs b/crates/rmcp/src/service/client/cache.rs index c91e8588..e9bb6cda 100644 --- a/crates/rmcp/src/service/client/cache.rs +++ b/crates/rmcp/src/service/client/cache.rs @@ -218,7 +218,7 @@ impl Peer { /// Missing `cacheScope` is treated as private. This is deliberately more /// conservative than the model's backwards-compatible wire default and /// prevents an older or malformed server response from becoming shareable. - pub(crate) async fn cache_response( + pub(crate) async fn cache_response_with_generation( &self, logical_key: String, value: R::PeerResp, @@ -278,6 +278,19 @@ impl Peer { ); } + #[cfg(test)] + pub(crate) async fn cache_response( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + ) { + let generation = self.capture_response_cache_generation().await; + self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation) + .await; + } + pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { let mut cache = self.response_cache.write().await; cache.generation = cache.generation.wrapping_add(1); diff --git a/docs/CLIENT_CACHING.md b/docs/CLIENT_CACHING.md index 610878e7..80f405e0 100644 --- a/docs/CLIENT_CACHING.md +++ b/docs/CLIENT_CACHING.md @@ -31,6 +31,8 @@ private entries while preserving public entries. Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or `clear_response_cache()` to clear held responses without changing the policy. +`with_max_entries()` bounds the in-memory store; the default is 512 entries and +a value of zero removes the limit. Cache keys include the method and result-affecting parameters: the cursor for paginated list methods and the URI for resource reads. MRTR retries containing @@ -39,3 +41,7 @@ invalidate every cached page for the corresponding method, while resource update notifications invalidate only the matching URI. When a cursor request fails, all cached pages for that list method are discarded so the next walk can restart from the beginning. + +Cache invalidation advances an internal generation. Responses from requests that +were already in flight before an invalidation or authorization-context change are +not written back into the cache. From 2e6fc21d5d0b10b2f2ef526c7708ae68f7bebbd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:58:55 +0000 Subject: [PATCH 13/48] test: record issue 974 validation diagnostics --- issue-974-validation-result.txt | 315 ++++++-------------------------- 1 file changed, 53 insertions(+), 262 deletions(-) diff --git a/issue-974-validation-result.txt b/issue-974-validation-result.txt index 4a9eb19a..27cb94a0 100644 --- a/issue-974-validation-result.txt +++ b/issue-974-validation-result.txt @@ -4,209 +4,8 @@ status=0 ===== whitespace ===== status=0 -===== workspace_check ===== -status=101 ---- last 200 log lines --- - | -230 | #[task_handler] - | ^^^^^^^^^^^^^^^ `dyn Fn(PromptContext<'a, Counter>) -> Pin>` cannot be sent between threads safely - | - = help: the trait `std::marker::Send` is not implemented for `dyn Fn(PromptContext<'a, Counter>) -> Pin>` - = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` -note: required because it appears within the type `PromptRoute` - --> crates/rmcp/src/handler/server/router/prompt.rs:10:12 - | - 10 | pub struct PromptRoute { - | ^^^^^^^^^^^ - = note: required because it appears within the type `(Cow<'static, str>, PromptRoute)` - = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, PromptRoute)>` to implement `std::marker::Send` -note: required because it appears within the type `hashbrown::map::HashMap, PromptRoute, RandomState>` - --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 -note: required because it appears within the type `HashMap, PromptRoute>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 -note: required because it appears within the type `PromptRouter` - --> crates/rmcp/src/handler/server/router/prompt.rs:110:12 - | -110 | pub struct PromptRouter { - | ^^^^^^^^^^^^ -note: required because it appears within the type `Counter` - --> examples/servers/src/common/counter.rs:77:12 - | - 77 | pub struct Counter { - | ^^^^^^^ -note: required because it's used within this `async` block - --> examples/servers/src/common/counter.rs:230:1 - | -230 | #[task_handler] - | ^^^^^^^^^^^^^^^ - = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` - = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-13449520427118935295.txt' - = note: consider using `--verbose` to print the full type name to the console - = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: `dyn futures::Future>` cannot be sent between threads safely - --> examples/servers/src/common/counter.rs:230:1 - | -230 | #[task_handler] - | ^^^^^^^^^^^^^^^ `dyn futures::Future>` cannot be sent between threads safely - | - = help: the trait `std::marker::Send` is not implemented for `dyn futures::Future>` - = note: required for `std::ptr::Unique>>` to implement `std::marker::Send` -note: required because it appears within the type `Box>>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/alloc/src/boxed.rs:234:11 -note: required because it appears within the type `Pin>>>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/core/src/pin.rs:1092:11 -note: required because it's used within this `async` fn body - --> crates/rmcp/src/handler/server/router/tool.rs:563:67 - | -563 | ) -> Result { - |  ___________________________________________________________________^ -564 | | let name = context.name(); -565 | | if self.disabled.contains(name) { -566 | | return Err(crate::ErrorData::invalid_params("tool not found", None)); -... | -578 | | Ok(result) -579 | | } - | |_____^ -note: required because it's used within this `async` fn body - --> examples/servers/src/common/counter.rs:228:1 - | -228 | #[tool_handler(meta = Meta(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -note: required because it's used within this `async` block - --> examples/servers/src/common/counter.rs:230:1 - | -230 | #[task_handler] - | ^^^^^^^^^^^^^^^ - = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` - = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-6481740063410040282.txt' - = note: consider using `--verbose` to print the full type name to the console - = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be shared between threads safely - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be shared between threads safely - | - = help: the trait `Sync` is not implemented for `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` - = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` -note: required because it appears within the type `ToolRoute` - --> crates/rmcp/src/handler/server/router/tool.rs:159:12 - | -159 | pub struct ToolRoute { - | ^^^^^^^^^ - = note: required because it appears within the type `(Cow<'static, str>, ToolRoute)` - = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, ToolRoute)>` to implement `std::marker::Send` -note: required because it appears within the type `hashbrown::map::HashMap, ToolRoute, RandomState>` - --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 -note: required because it appears within the type `HashMap, ToolRoute>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 -note: required because it appears within the type `ToolRouter` - --> crates/rmcp/src/handler/server/router/tool.rs:325:12 - | -325 | pub struct ToolRouter { - | ^^^^^^^^^^ -note: required because it appears within the type `TaskDemo` - --> examples/servers/src/common/task_demo.rs:41:12 - | - 41 | pub struct TaskDemo { - | ^^^^^^^^ -note: required because it's used within this `async` block - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ - = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` - = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-4286591992877008387.txt' - = note: consider using `--verbose` to print the full type name to the console - = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be sent between threads safely - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` cannot be sent between threads safely - | - = help: the trait `std::marker::Send` is not implemented for `dyn Fn(ToolCallContext<'s, TaskDemo>) -> Pin>` - = note: required for `Arc) -> Pin>>` to implement `std::marker::Send` -note: required because it appears within the type `ToolRoute` - --> crates/rmcp/src/handler/server/router/tool.rs:159:12 - | -159 | pub struct ToolRoute { - | ^^^^^^^^^ - = note: required because it appears within the type `(Cow<'static, str>, ToolRoute)` - = note: required for `hashbrown::raw::RawTable<(Cow<'static, str>, ToolRoute)>` to implement `std::marker::Send` -note: required because it appears within the type `hashbrown::map::HashMap, ToolRoute, RandomState>` - --> /rust/deps/hashbrown-0.16.1/src/map.rs:185:11 -note: required because it appears within the type `HashMap, ToolRoute>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/std/src/collections/hash/map.rs:247:11 -note: required because it appears within the type `ToolRouter` - --> crates/rmcp/src/handler/server/router/tool.rs:325:12 - | -325 | pub struct ToolRouter { - | ^^^^^^^^^^ -note: required because it appears within the type `TaskDemo` - --> examples/servers/src/common/task_demo.rs:41:12 - | - 41 | pub struct TaskDemo { - | ^^^^^^^^ -note: required because it's used within this `async` block - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ - = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` - = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-4286591992877008387.txt' - = note: consider using `--verbose` to print the full type name to the console - = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: `dyn futures::Future>` cannot be sent between threads safely - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ `dyn futures::Future>` cannot be sent between threads safely - | - = help: the trait `std::marker::Send` is not implemented for `dyn futures::Future>` - = note: required for `std::ptr::Unique>>` to implement `std::marker::Send` -note: required because it appears within the type `Box>>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/alloc/src/boxed.rs:234:11 -note: required because it appears within the type `Pin>>>` - --> /rustc/31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd/library/core/src/pin.rs:1092:11 -note: required because it's used within this `async` fn body - --> crates/rmcp/src/handler/server/router/tool.rs:563:67 - | -563 | ) -> Result { - |  ___________________________________________________________________^ -564 | | let name = context.name(); -565 | | if self.disabled.contains(name) { -566 | | return Err(crate::ErrorData::invalid_params("tool not found", None)); -... | -578 | | Ok(result) -579 | | } - | |_____^ -note: required because it's used within this `async` fn body - --> examples/servers/src/common/task_demo.rs:91:1 - | - 91 | #[tool_handler] - | ^^^^^^^^^^^^^^^ -note: required because it's used within this `async` block - --> examples/servers/src/common/task_demo.rs:92:1 - | - 92 | #[task_handler] - | ^^^^^^^^^^^^^^^ - = note: required for the cast from `Pin>` to `Pin, ...>> + Send>>` - = note: the full name for the type has been written to '/home/runner/work/rust-sdk/rust-sdk/target/debug/examples/servers_complex_auth_streamhttp-744dd7581caaaab8.long-type-6481740063410040282.txt' - = note: consider using `--verbose` to print the full type name to the console - = note: this error originates in the attribute macro `task_handler` (in Nightly builds, run with -Z macro-backtrace for more info) - -For more information about this error, try `rustc --explain E0277`. -error: could not compile `mcp-server-examples` (example "servers_calculator_stdio") due to 8 previous errors -warning: build failed, waiting for other jobs to finish... -warning: `mcp-conformance` (bin "conformance-client" test) generated 7 warnings -Some errors have detailed explanations: E0277, E0432. -For more information about an error, try `rustc --explain E0277`. -error: could not compile `mcp-server-examples` (example "servers_complex_auth_streamhttp") due to 9 previous errors +===== rmcp_check ===== +status=0 ===== workspace_clippy ===== status=0 @@ -214,67 +13,59 @@ status=0 ===== all_feature_tests ===== status=0 +FEATURES= ===== rmcp_no_local_tests ===== status=101 ---- last 200 log lines --- - Compiling libc v0.2.186 - Compiling num-traits v0.2.19 - Compiling sync_wrapper v1.0.2 - Compiling rmcp v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp) - Compiling rstest_macros v0.26.1 - Compiling axum-core v0.5.6 - Compiling chrono v0.4.45 - Compiling errno v0.3.14 - Compiling parking_lot_core v0.9.12 - Compiling parking_lot v0.12.5 - Compiling signal-hook-registry v1.4.8 - Compiling socket2 v0.6.4 - Compiling mio v1.2.1 - Compiling tokio v1.52.3 - Compiling schemars v1.2.1 - Compiling rmcp-macros v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp-macros) - Compiling rstest v0.26.1 - Compiling url v2.5.8 - Compiling hyper v1.10.1 - Compiling tokio-util v0.7.18 - Compiling tower v0.5.3 - Compiling hyper-util v0.1.20 - Compiling axum v0.8.9 -warning: struct `Sum` is never constructed - --> crates/rmcp/tests/test_prompt_routers.rs:24:8 - | -24 | struct Sum { - | ^^^ - | - = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default - -warning: `rmcp` (test "test_prompt_routers") generated 1 warning -warning: struct `Sum` is never constructed - --> crates/rmcp/tests/test_tool_routers.rs:25:8 - | -25 | struct Sum { - | ^^^ - | - = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default - -warning: `rmcp` (test "test_tool_routers") generated 1 warning -error[E0425]: cannot find function `stdio` in module `rmcp::transport` - --> crates/rmcp/tests/test_stdio_response_concurrency.rs:106:61 - | -106 | let server = LargeResponseServer.serve(rmcp::transport::stdio()).await?; - | ^^^^^ not found in `rmcp::transport` - | -note: found an item that was configured out - --> crates/rmcp/src/transport.rs:94:13 - | - 93 | #[cfg(feature = "transport-io")] - | ------------------------ the item is gated behind the `transport-io` feature - 94 | pub use io::stdio; - | ^^^^^ - -For more information about this error, try `rustc --explain E0425`. -error: could not compile `rmcp` (test "test_stdio_response_concurrency") due to 1 previous error -warning: build failed, waiting for other jobs to finish... +--- last 300 log lines --- + Compiling libc v0.2.186 + Compiling num-traits v0.2.19 + Compiling sync_wrapper v1.0.2 + Compiling rmcp v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp) + Compiling axum-core v0.5.6 + Compiling rstest_macros v0.26.1 + Compiling chrono v0.4.45 + Compiling parking_lot_core v0.9.12 + Compiling errno v0.3.14 + Compiling signal-hook-registry v1.4.8 + Compiling parking_lot v0.12.5 + Compiling mio v1.2.1 + Compiling socket2 v0.6.4 + Compiling schemars v1.2.1 + Compiling tokio v1.52.3 + Compiling rmcp-macros v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp-macros) + Compiling rstest v0.26.1 + Compiling url v2.5.8 + Compiling hyper v1.10.1 + Compiling tokio-util v0.7.18 + Compiling tower v0.5.3 + Compiling hyper-util v0.1.20 + Compiling axum v0.8.9 +warning: struct `Sum` is never constructed + --> crates/rmcp/tests/test_prompt_routers.rs:24:8 + | +24 | struct Sum { + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `rmcp` (test "test_prompt_routers") generated 1 warning +error[E0425]: cannot find function `stdio` in module `rmcp::transport` + --> crates/rmcp/tests/test_cancelled_response.rs:130:61 + | +130 | let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?; + | ^^^^^ not found in `rmcp::transport` + | +note: found an item that was configured out + --> crates/rmcp/src/transport.rs:94:13 + | + 93 | #[cfg(feature = "transport-io")] + | ------------------------ the item is gated behind the `transport-io` feature + 94 | pub use io::stdio; + | ^^^^^ + +For more information about this error, try `rustc --explain E0425`. +error: could not compile `rmcp` (test "test_cancelled_response") due to 1 previous error +warning: build failed, waiting for other jobs to finish... ===== docs ===== status=0 From abd8f466ff35d429b0be3f81ee1bd97198dbd342 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:00:34 +0100 Subject: [PATCH 14/48] ci: run complete issue 974 validation matrix --- .github/workflows/harden-issue-974.yml | 127 +++++++++++++++++-------- 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml index a18e150b..9ae5cdd5 100644 --- a/.github/workflows/harden-issue-974.yml +++ b/.github/workflows/harden-issue-974.yml @@ -1,4 +1,4 @@ -name: Finalize issue 974 validation +name: Complete validation for issue 974 on: push: @@ -11,41 +11,38 @@ permissions: contents: write jobs: - finalize-and-validate: + validate: runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 120 steps: - name: Check out issue branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: feat/issue-974-client-response-cache fetch-depth: 0 - - name: Install toolchains - run: | - rustup toolchain install stable --component clippy - rustup toolchain install nightly --component rustfmt + - name: Install stable Rust with Clippy + uses: dtolnay/rust-toolchain@stable + with: + components: clippy - - name: Apply final hardening edits - run: | - python scripts/finalize_issue_974.py - cargo +nightly fmt --all - git diff --check + - name: Install nightly Rust for formatting and public API checks + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt - - name: Commit hardened implementation - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - crates/rmcp/src/service/client.rs \ - crates/rmcp/src/service/client/cache.rs \ - docs/CLIENT_CACHING.md - git commit -m "fix(client): prevent stale cache writes" || true - git push origin HEAD:feat/issue-974-client-response-cache + - name: Install cargo-semver-checks + uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks + + - name: Install cargo-public-api + uses: taiki-e/install-action@v2 + with: + tool: cargo-public-api - - name: Run repository checks and capture logs + - name: Run and record repository checks shell: bash - continue-on-error: true run: | set +e : > issue-974-validation-result.txt @@ -61,39 +58,91 @@ jobs: echo "status=${status}" >> issue-974-validation-result.txt if [ "$status" -ne 0 ]; then failed=1 - echo "--- last 300 log lines ---" >> issue-974-validation-result.txt - tail -n 300 "$log" >> issue-974-validation-result.txt + echo "--- last 250 log lines ---" >> issue-974-validation-result.txt + tail -n 250 "$log" >> issue-974-validation-result.txt fi echo >> issue-974-validation-result.txt } + FEATURES=$(python - <<'PY' + import json + import subprocess + + metadata = json.loads(subprocess.check_output( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + text=True, + )) + package = next(package for package in metadata["packages"] if package["name"] == "rmcp") + features = sorted( + name for name in package["features"] + if not name.startswith("__") and name != "local" + ) + print(",".join(features)) + PY + ) + echo "FEATURES=${FEATURES}" >> issue-974-validation-result.txt + case ",${FEATURES}," in + *,transport-io,*) ;; + *) echo "required feature transport-io missing" >> issue-974-validation-result.txt; failed=1 ;; + esac + run_check nightly_fmt cargo +nightly fmt --all -- --check run_check whitespace git diff --check run_check rmcp_check cargo check -p rmcp --all-targets --all-features run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings run_check all_feature_tests cargo test --all-features - - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - echo "FEATURES=${FEATURES}" >> issue-974-validation-result.txt run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" - run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps run_check conformance_build cargo build -p mcp-conformance + run_check semver_default cargo semver-checks --package rmcp --baseline-rev main --release-type minor --only-explicit-features --features default + run_check semver_no_local cargo semver-checks --package rmcp --baseline-rev main --release-type minor --only-explicit-features --features "$FEATURES" + run_check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force main..HEAD + run_check public_api_no_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force main..HEAD + echo "===== examples =====" >> issue-974-validation-result.txt + examples_failed=0 + : > /tmp/examples.log + for dir in examples/*/; do + if [ ! -f "$dir/Cargo.toml" ]; then + continue + fi + if [[ "$dir" == *"wasi"* ]]; then + rustup target add wasm32-wasip2 >> /tmp/examples.log 2>&1 + cargo build --manifest-path "$dir/Cargo.toml" --target wasm32-wasip2 >> /tmp/examples.log 2>&1 || examples_failed=1 + else + cargo build --manifest-path "$dir/Cargo.toml" --all-features --tests >> /tmp/examples.log 2>&1 || examples_failed=1 + cargo test --manifest-path "$dir/Cargo.toml" --all-features --all-targets >> /tmp/examples.log 2>&1 || examples_failed=1 + fi + done + echo "status=${examples_failed}" >> issue-974-validation-result.txt + if [ "$examples_failed" -ne 0 ]; then + failed=1 + tail -n 250 /tmp/examples.log >> issue-974-validation-result.txt + fi + echo >> issue-974-validation-result.txt echo "overall_failed=${failed}" >> issue-974-validation-result.txt - - name: Commit validation report + - name: Run spelling check + id: typos + continue-on-error: true + uses: crate-ci/typos@master + + - name: Append spelling result if: always() shell: bash + run: | + if [ "${{ steps.typos.outcome }}" = "success" ]; then + printf '\n===== typos =====\nstatus=0\n' >> issue-974-validation-result.txt + else + printf '\n===== typos =====\nstatus=1\n' >> issue-974-validation-result.txt + sed -i 's/overall_failed=0/overall_failed=1/' issue-974-validation-result.txt + fi + + - name: Commit validation report + if: always() run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if [ ! -f issue-974-validation-result.txt ]; then - printf 'validation step did not create a report\n' > issue-974-validation-result.txt - fi git add issue-974-validation-result.txt - git commit -m "test: record issue 974 validation diagnostics" || true + git commit -m "test: record complete issue 974 validation" || true git push origin HEAD:feat/issue-974-client-response-cache From 02461302c7feabfe3218b496a371e9c0a597621b Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:03:56 +0100 Subject: [PATCH 15/48] ci: validate final issue 974 implementation --- .github/workflows/harden-issue-974.yml | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml index 9ae5cdd5..d87adf2c 100644 --- a/.github/workflows/harden-issue-974.yml +++ b/.github/workflows/harden-issue-974.yml @@ -7,6 +7,10 @@ on: paths: - .github/workflows/harden-issue-974.yml +concurrency: + group: issue-974-branch-validation + cancel-in-progress: true + permissions: contents: write @@ -31,6 +35,83 @@ jobs: with: components: rustfmt + - name: Add final regression coverage and documentation link + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + readme = Path("README.md") + readme_text = readme.read_text() + link = "For client-side TTL caching, configuration, and authorization partitioning, see [Client response caching](docs/CLIENT_CACHING.md)." + marker = "\n\n### Build a Server" + if link not in readme_text: + if marker not in readme_text: + raise RuntimeError("README client-section marker not found") + readme_text = readme_text.replace( + marker, + f"\n\n{link}\n\n### Build a Server", + 1, + ) + readme.write_text(readme_text) + + client = Path("crates/rmcp/src/service/client.rs") + client_text = client.read_text() + test_name = "cursor_transport_error_discards_all_cached_pages" + if test_name not in client_text: + marker = " #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {" + if marker not in client_text: + raise RuntimeError("client test insertion marker not found") + test = ''' #[tokio::test] + async fn cursor_transport_error_discards_all_cached_pages() { + let peer = disconnected_peer(); + let mut cached_keys = Vec::new(); + for cursor in [None::, Some("page-a".into())] { + let params = cursor.map(|cursor| { + PaginatedRequestParams::default().with_cursor(Some(cursor)) + }); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result( + Some(5_000), + Some(CacheScope::Public), + )), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + cached_keys.push(key); + } + + let missing_page = Some( + PaginatedRequestParams::default() + .with_cursor(Some("missing-page".into())), + ); + assert!(matches!( + peer.list_tools(missing_page).await, + Err(ServiceError::TransportClosed) + )); + for key in cached_keys { + assert!(peer.cached_response(&key).await.is_none()); + } + } + + ''' + client_text = client_text.replace(marker, test + marker, 1) + client.write_text(client_text) + PY + cargo +nightly fmt --all + git diff --check + + - name: Commit final source edits + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md crates/rmcp/src/service/client.rs + git commit -m "test(client): cover invalid cursor cache eviction" || true + git push origin HEAD:feat/issue-974-client-response-cache + - name: Install cargo-semver-checks uses: taiki-e/install-action@v2 with: From 5360196e253321c05e5b16fff2b9f8850df54b00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:04:23 +0000 Subject: [PATCH 16/48] test(client): cover invalid cursor cache eviction --- README.md | 2 ++ crates/rmcp/src/service/client.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 68f3343c..bcf0fc4d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ async fn main() -> Result<(), Box> { ``` +For client-side TTL caching, configuration, and authorization partitioning, see [Client response caching](docs/CLIENT_CACHING.md). + ### Build a Server
diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 6722b7ea..fa906816 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1507,6 +1507,35 @@ mod tests { assert!(peer.cached_response(&second).await.is_some()); } + #[tokio::test] + async fn cursor_transport_error_discards_all_cached_pages() { + let peer = disconnected_peer(); + let mut cached_keys = Vec::new(); + for cursor in [None::, Some("page-a".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + cached_keys.push(key); + } + + let missing_page = + Some(PaginatedRequestParams::default().with_cursor(Some("missing-page".into()))); + assert!(matches!( + peer.list_tools(missing_page).await, + Err(ServiceError::TransportClosed) + )); + for key in cached_keys { + assert!(peer.cached_response(&key).await.is_none()); + } + } + #[test] fn mrtr_retry_parameters_are_not_cacheable() { let params = ReadResourceRequestParams::new("file:///example.txt") From 1967948c1e11f12a0a01fb976024c8ed274ece12 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:10:49 +0100 Subject: [PATCH 17/48] chore: remove temporary issue 974 validation workflow --- .github/workflows/harden-issue-974.yml | 229 ------------------------- 1 file changed, 229 deletions(-) delete mode 100644 .github/workflows/harden-issue-974.yml diff --git a/.github/workflows/harden-issue-974.yml b/.github/workflows/harden-issue-974.yml deleted file mode 100644 index d87adf2c..00000000 --- a/.github/workflows/harden-issue-974.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: Complete validation for issue 974 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/harden-issue-974.yml - -concurrency: - group: issue-974-branch-validation - cancel-in-progress: true - -permissions: - contents: write - -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - name: Check out issue branch - uses: actions/checkout@v7 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install stable Rust with Clippy - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly Rust for formatting and public API checks - uses: dtolnay/rust-toolchain@nightly - with: - components: rustfmt - - - name: Add final regression coverage and documentation link - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - readme = Path("README.md") - readme_text = readme.read_text() - link = "For client-side TTL caching, configuration, and authorization partitioning, see [Client response caching](docs/CLIENT_CACHING.md)." - marker = "
\n\n### Build a Server" - if link not in readme_text: - if marker not in readme_text: - raise RuntimeError("README client-section marker not found") - readme_text = readme_text.replace( - marker, - f"\n\n{link}\n\n### Build a Server", - 1, - ) - readme.write_text(readme_text) - - client = Path("crates/rmcp/src/service/client.rs") - client_text = client.read_text() - test_name = "cursor_transport_error_discards_all_cached_pages" - if test_name not in client_text: - marker = " #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {" - if marker not in client_text: - raise RuntimeError("client test insertion marker not found") - test = ''' #[tokio::test] - async fn cursor_transport_error_discards_all_cached_pages() { - let peer = disconnected_peer(); - let mut cached_keys = Vec::new(); - for cursor in [None::, Some("page-a".into())] { - let params = cursor.map(|cursor| { - PaginatedRequestParams::default().with_cursor(Some(cursor)) - }); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result( - Some(5_000), - Some(CacheScope::Public), - )), - Some(5_000), - Some(CacheScope::Public), - ) - .await; - cached_keys.push(key); - } - - let missing_page = Some( - PaginatedRequestParams::default() - .with_cursor(Some("missing-page".into())), - ); - assert!(matches!( - peer.list_tools(missing_page).await, - Err(ServiceError::TransportClosed) - )); - for key in cached_keys { - assert!(peer.cached_response(&key).await.is_none()); - } - } - - ''' - client_text = client_text.replace(marker, test + marker, 1) - client.write_text(client_text) - PY - cargo +nightly fmt --all - git diff --check - - - name: Commit final source edits - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add README.md crates/rmcp/src/service/client.rs - git commit -m "test(client): cover invalid cursor cache eviction" || true - git push origin HEAD:feat/issue-974-client-response-cache - - - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks - - - name: Install cargo-public-api - uses: taiki-e/install-action@v2 - with: - tool: cargo-public-api - - - name: Run and record repository checks - shell: bash - run: | - set +e - : > issue-974-validation-result.txt - failed=0 - - run_check() { - name="$1" - shift - log="/tmp/${name}.log" - echo "===== ${name} =====" >> issue-974-validation-result.txt - "$@" >"$log" 2>&1 - status=$? - echo "status=${status}" >> issue-974-validation-result.txt - if [ "$status" -ne 0 ]; then - failed=1 - echo "--- last 250 log lines ---" >> issue-974-validation-result.txt - tail -n 250 "$log" >> issue-974-validation-result.txt - fi - echo >> issue-974-validation-result.txt - } - - FEATURES=$(python - <<'PY' - import json - import subprocess - - metadata = json.loads(subprocess.check_output( - ["cargo", "metadata", "--no-deps", "--format-version", "1"], - text=True, - )) - package = next(package for package in metadata["packages"] if package["name"] == "rmcp") - features = sorted( - name for name in package["features"] - if not name.startswith("__") and name != "local" - ) - print(",".join(features)) - PY - ) - echo "FEATURES=${FEATURES}" >> issue-974-validation-result.txt - case ",${FEATURES}," in - *,transport-io,*) ;; - *) echo "required feature transport-io missing" >> issue-974-validation-result.txt; failed=1 ;; - esac - - run_check nightly_fmt cargo +nightly fmt --all -- --check - run_check whitespace git diff --check - run_check rmcp_check cargo check -p rmcp --all-targets --all-features - run_check workspace_clippy cargo clippy --all-targets --all-features -- -D warnings - run_check all_feature_tests cargo test --all-features - run_check rmcp_no_local_tests cargo test -p rmcp --features "$FEATURES" - run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - run_check conformance_build cargo build -p mcp-conformance - run_check semver_default cargo semver-checks --package rmcp --baseline-rev main --release-type minor --only-explicit-features --features default - run_check semver_no_local cargo semver-checks --package rmcp --baseline-rev main --release-type minor --only-explicit-features --features "$FEATURES" - run_check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force main..HEAD - run_check public_api_no_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force main..HEAD - - echo "===== examples =====" >> issue-974-validation-result.txt - examples_failed=0 - : > /tmp/examples.log - for dir in examples/*/; do - if [ ! -f "$dir/Cargo.toml" ]; then - continue - fi - if [[ "$dir" == *"wasi"* ]]; then - rustup target add wasm32-wasip2 >> /tmp/examples.log 2>&1 - cargo build --manifest-path "$dir/Cargo.toml" --target wasm32-wasip2 >> /tmp/examples.log 2>&1 || examples_failed=1 - else - cargo build --manifest-path "$dir/Cargo.toml" --all-features --tests >> /tmp/examples.log 2>&1 || examples_failed=1 - cargo test --manifest-path "$dir/Cargo.toml" --all-features --all-targets >> /tmp/examples.log 2>&1 || examples_failed=1 - fi - done - echo "status=${examples_failed}" >> issue-974-validation-result.txt - if [ "$examples_failed" -ne 0 ]; then - failed=1 - tail -n 250 /tmp/examples.log >> issue-974-validation-result.txt - fi - echo >> issue-974-validation-result.txt - echo "overall_failed=${failed}" >> issue-974-validation-result.txt - - - name: Run spelling check - id: typos - continue-on-error: true - uses: crate-ci/typos@master - - - name: Append spelling result - if: always() - shell: bash - run: | - if [ "${{ steps.typos.outcome }}" = "success" ]; then - printf '\n===== typos =====\nstatus=0\n' >> issue-974-validation-result.txt - else - printf '\n===== typos =====\nstatus=1\n' >> issue-974-validation-result.txt - sed -i 's/overall_failed=0/overall_failed=1/' issue-974-validation-result.txt - fi - - - name: Commit validation report - if: always() - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add issue-974-validation-result.txt - git commit -m "test: record complete issue 974 validation" || true - git push origin HEAD:feat/issue-974-client-response-cache From 11e575d2ad27a9123ad338e63d0b8595f12be24d Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:11:01 +0100 Subject: [PATCH 18/48] chore: remove temporary issue 974 finalization script --- scripts/finalize_issue_974.py | 102 ---------------------------------- 1 file changed, 102 deletions(-) delete mode 100644 scripts/finalize_issue_974.py diff --git a/scripts/finalize_issue_974.py b/scripts/finalize_issue_974.py deleted file mode 100644 index a8ca7a91..00000000 --- a/scripts/finalize_issue_974.py +++ /dev/null @@ -1,102 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - file = ROOT / path - text = file.read_text() - count = text.count(old) - if count != 1: - raise RuntimeError(f"{path}: expected one match, found {count}") - file.write_text(text.replace(old, new, 1)) - - -# Keep the old direct seeding helper for unit tests while production writes -# require a generation captured before the transport request. -replace_once( - "crates/rmcp/src/service/client/cache.rs", - """ pub(crate) async fn cache_response(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n ) {\n""", - """ pub(crate) async fn cache_response_with_generation(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n ) {\n""", -) - -replace_once( - "crates/rmcp/src/service/client/cache.rs", - """ pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {\n""", - """ #[cfg(test)]\n pub(crate) async fn cache_response(\n &self,\n logical_key: String,\n value: R::PeerResp,\n ttl_ms: Option,\n cache_scope: Option,\n ) {\n let generation = self.capture_response_cache_generation().await;\n self.cache_response_with_generation(\n logical_key,\n value,\n ttl_ms,\n cache_scope,\n generation,\n )\n .await;\n }\n\n pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {\n""", -) - -replace_once( - "crates/rmcp/src/service/client.rs", - """pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL};\n""", - """pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL};\nuse cache::CacheGeneration;\n""", -) - -replace_once( - "crates/rmcp/src/service/client.rs", - """ async fn cache_result(\n &self,\n cache_key: Option,\n ttl_ms: Option,\n cache_scope: Option,\n result: ServerResult,\n ) {\n let Some(cache_key) = cache_key else {\n return;\n };\n self.cache_response(cache_key, result, ttl_ms, cache_scope)\n .await;\n }\n""", - """ async fn cache_result(\n &self,\n cache_key: Option,\n ttl_ms: Option,\n cache_scope: Option,\n generation: CacheGeneration,\n result: ServerResult,\n ) {\n let Some(cache_key) = cache_key else {\n return;\n };\n self.cache_response_with_generation(\n cache_key,\n result,\n ttl_ms,\n cache_scope,\n generation,\n )\n .await;\n }\n""", -) - -# resources/read: capture after a miss and before crossing the transport. -replace_once( - "crates/rmcp/src/service/client.rs", - """ let result = self\n .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest {\n""", - """ let generation = self.capture_response_cache_generation().await;\n let result = self\n .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest {\n""", -) -replace_once( - "crates/rmcp/src/service/client.rs", - """ result.cache_scope,\n ServerResult::ReadResourceResult(result.clone()),\n""", - """ result.cache_scope,\n generation,\n ServerResult::ReadResourceResult(result.clone()),\n""", -) - -# The four list methods all use the same miss -> capture -> request pattern. -for marker in [ - "let uses_cursor = request_uses_cursor(¶ms);", -]: - text = (ROOT / "crates/rmcp/src/service/client.rs").read_text() - expected = 4 - actual = text.count(marker) - if actual != expected: - raise RuntimeError(f"client.rs: expected {expected} cursor markers, found {actual}") - text = text.replace( - marker, - "let generation = self.capture_response_cache_generation().await;\n " + marker, - ) - (ROOT / "crates/rmcp/src/service/client.rs").write_text(text) - -# Add the generation argument to the four list cache writes. -client = ROOT / "crates/rmcp/src/service/client.rs" -text = client.read_text() -for variant in [ - "ListPromptsResult", - "ListResourcesResult", - "ListResourceTemplatesResult", - "ListToolsResult", -]: - old = f""" result.cache_scope,\n ServerResult::{variant}(result.clone()),\n""" - new = f""" result.cache_scope,\n generation,\n ServerResult::{variant}(result.clone()),\n""" - count = text.count(old) - if count != 1: - raise RuntimeError(f"client.rs: expected one {variant} cache write, found {count}") - text = text.replace(old, new, 1) -client.write_text(text) - -# Add regression tests for stale in-flight writes and the entry bound. -replace_once( - "crates/rmcp/src/service/client.rs", - """ #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {\n""", - """ #[tokio::test]\n async fn invalidation_suppresses_an_in_flight_cache_write() {\n let peer = disconnected_peer();\n let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None);\n let generation = peer.capture_response_cache_generation().await;\n peer.invalidate_tool_cache().await;\n peer.cache_response_with_generation(\n key.clone(),\n ServerResult::ListToolsResult(tools_result(\n Some(5_000),\n Some(CacheScope::Private),\n )),\n Some(5_000),\n Some(CacheScope::Private),\n generation,\n )\n .await;\n\n assert!(peer.cached_response(&key).await.is_none());\n }\n\n #[tokio::test]\n async fn entry_limit_evicts_the_oldest_response() {\n let peer = disconnected_peer();\n peer.set_response_cache_config(\n ClientCacheConfig::default().with_max_entries(1),\n )\n .await;\n let first = resource_read_cache_key_for_uri(\"file:///first\");\n let second = resource_read_cache_key_for_uri(\"file:///second\");\n peer.cache_response(\n first.clone(),\n ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())),\n Some(5_000),\n Some(CacheScope::Private),\n )\n .await;\n tokio::time::sleep(Duration::from_millis(1)).await;\n peer.cache_response(\n second.clone(),\n ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())),\n Some(5_000),\n Some(CacheScope::Private),\n )\n .await;\n\n assert!(peer.cached_response(&first).await.is_none());\n assert!(peer.cached_response(&second).await.is_some());\n }\n\n #[test]\n fn mrtr_retry_parameters_are_not_cacheable() {\n""", -) - -# Document the final two guarantees. -docs = ROOT / "docs/CLIENT_CACHING.md" -text = docs.read_text() -text = text.replace( - "Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or\n`clear_response_cache()` to clear held responses without changing the policy.\n", - "Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or\n`clear_response_cache()` to clear held responses without changing the policy.\n`with_max_entries()` bounds the in-memory store; the default is 512 entries and\na value of zero removes the limit.\n", -) -text += "\nCache invalidation advances an internal generation. Responses from requests that\nwere already in flight before an invalidation or authorization-context change are\nnot written back into the cache.\n" -docs.write_text(text) - -print("issue 974 finalization edits applied") From 7de020313dfbcef950b415e694ecc036fa37d123 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:11:15 +0100 Subject: [PATCH 19/48] chore: remove temporary issue 974 validation report --- issue-974-validation-result.txt | 76 --------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 issue-974-validation-result.txt diff --git a/issue-974-validation-result.txt b/issue-974-validation-result.txt deleted file mode 100644 index 27cb94a0..00000000 --- a/issue-974-validation-result.txt +++ /dev/null @@ -1,76 +0,0 @@ -===== nightly_fmt ===== -status=0 - -===== whitespace ===== -status=0 - -===== rmcp_check ===== -status=0 - -===== workspace_clippy ===== -status=0 - -===== all_feature_tests ===== -status=0 - -FEATURES= -===== rmcp_no_local_tests ===== -status=101 ---- last 300 log lines --- - Compiling libc v0.2.186 - Compiling num-traits v0.2.19 - Compiling sync_wrapper v1.0.2 - Compiling rmcp v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp) - Compiling axum-core v0.5.6 - Compiling rstest_macros v0.26.1 - Compiling chrono v0.4.45 - Compiling parking_lot_core v0.9.12 - Compiling errno v0.3.14 - Compiling signal-hook-registry v1.4.8 - Compiling parking_lot v0.12.5 - Compiling mio v1.2.1 - Compiling socket2 v0.6.4 - Compiling schemars v1.2.1 - Compiling tokio v1.52.3 - Compiling rmcp-macros v2.2.0 (/home/runner/work/rust-sdk/rust-sdk/crates/rmcp-macros) - Compiling rstest v0.26.1 - Compiling url v2.5.8 - Compiling hyper v1.10.1 - Compiling tokio-util v0.7.18 - Compiling tower v0.5.3 - Compiling hyper-util v0.1.20 - Compiling axum v0.8.9 -warning: struct `Sum` is never constructed - --> crates/rmcp/tests/test_prompt_routers.rs:24:8 - | -24 | struct Sum { - | ^^^ - | - = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default - -warning: `rmcp` (test "test_prompt_routers") generated 1 warning -error[E0425]: cannot find function `stdio` in module `rmcp::transport` - --> crates/rmcp/tests/test_cancelled_response.rs:130:61 - | -130 | let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?; - | ^^^^^ not found in `rmcp::transport` - | -note: found an item that was configured out - --> crates/rmcp/src/transport.rs:94:13 - | - 93 | #[cfg(feature = "transport-io")] - | ------------------------ the item is gated behind the `transport-io` feature - 94 | pub use io::stdio; - | ^^^^^ - -For more information about this error, try `rustc --explain E0425`. -error: could not compile `rmcp` (test "test_cancelled_response") due to 1 previous error -warning: build failed, waiting for other jobs to finish... - -===== docs ===== -status=0 - -===== conformance_build ===== -status=0 - -overall_failed=1 From 483532b137e9e962997c39972ad76fb1c6f900b7 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:14:19 +0100 Subject: [PATCH 20/48] ci: run final issue 974 compliance check --- .github/workflows/final-issue-974-check.yml | 106 ++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/final-issue-974-check.yml diff --git a/.github/workflows/final-issue-974-check.yml b/.github/workflows/final-issue-974-check.yml new file mode 100644 index 00000000..666a9e6b --- /dev/null +++ b/.github/workflows/final-issue-974-check.yml @@ -0,0 +1,106 @@ +name: Final issue 974 compliance check + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/final-issue-974-check.yml + +permissions: + contents: write + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install toolchains + run: | + rustup toolchain install stable --component clippy + rustup toolchain install nightly --component rustfmt + + - name: Install API compatibility tools + uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks,cargo-public-api + + - name: Run checks and record results + shell: bash + run: | + set +e + : > final-issue-974-check.txt + failed=0 + + run_check() { + name="$1" + shift + log="/tmp/${name}.log" + "$@" >"$log" 2>&1 + status=$? + printf '%s=%s\n' "$name" "$status" >> final-issue-974-check.txt + if [ "$status" -ne 0 ]; then + failed=1 + printf '\n--- %s log ---\n' "$name" >> final-issue-974-check.txt + tail -n 250 "$log" >> final-issue-974-check.txt + printf '\n--- end %s log ---\n' "$name" >> final-issue-974-check.txt + fi + } + + FEATURES=$(python - <<'PY' + import tomllib + with open('crates/rmcp/Cargo.toml', 'rb') as file: + features = tomllib.load(file)['features'] + print(','.join(sorted( + name for name in features + if name != 'local' and not name.startswith('__') + ))) + PY + ) + printf 'features=%s\n' "$FEATURES" >> final-issue-974-check.txt + + run_check nightly_fmt cargo +nightly fmt --all -- --check + run_check whitespace git diff --check + run_check rmcp_check cargo check -p rmcp --all-targets --all-features + run_check clippy cargo clippy --all-targets --all-features -- -D warnings + run_check all_feature_tests cargo test --all-features + run_check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + run_check no_local_tests cargo test -p rmcp --features "$FEATURES" + run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + run_check conformance_build cargo build -p mcp-conformance + run_check semver_default cargo semver-checks --package rmcp --baseline-rev origin/main --release-type minor --only-explicit-features --features default + run_check semver_no_local cargo semver-checks --package rmcp --baseline-rev origin/main --release-type minor --only-explicit-features --features "$FEATURES" + run_check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force origin/main..HEAD + run_check public_api_no_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force origin/main..HEAD + + printf 'overall=%s\n' "$failed" >> final-issue-974-check.txt + + - name: Run spelling check + id: typos + continue-on-error: true + uses: crate-ci/typos@master + + - name: Record spelling result + if: always() + shell: bash + run: | + if [ "${{ steps.typos.outcome }}" = success ]; then + printf 'typos=0\n' >> final-issue-974-check.txt + else + printf 'typos=1\n' >> final-issue-974-check.txt + sed -i 's/overall=0/overall=1/' final-issue-974-check.txt + fi + + - name: Commit result + if: always() + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add final-issue-974-check.txt + git commit -m "test: record final issue 974 compliance check" || true + git push origin HEAD:feat/issue-974-client-response-cache From 8bab05c80adabfb53159fb97a011b9bd35df5483 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:24:45 +0100 Subject: [PATCH 21/48] ci: simplify final issue 974 compliance check --- .github/workflows/final-issue-974-check.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/final-issue-974-check.yml b/.github/workflows/final-issue-974-check.yml index 666a9e6b..211ea4ac 100644 --- a/.github/workflows/final-issue-974-check.yml +++ b/.github/workflows/final-issue-974-check.yml @@ -13,7 +13,7 @@ permissions: jobs: validate: runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 with: @@ -25,11 +25,6 @@ jobs: rustup toolchain install stable --component clippy rustup toolchain install nightly --component rustfmt - - name: Install API compatibility tools - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks,cargo-public-api - - name: Run checks and record results shell: bash run: | @@ -63,6 +58,7 @@ jobs: PY ) printf 'features=%s\n' "$FEATURES" >> final-issue-974-check.txt + test -n "$FEATURES" || failed=1 run_check nightly_fmt cargo +nightly fmt --all -- --check run_check whitespace git diff --check @@ -73,10 +69,6 @@ jobs: run_check no_local_tests cargo test -p rmcp --features "$FEATURES" run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps run_check conformance_build cargo build -p mcp-conformance - run_check semver_default cargo semver-checks --package rmcp --baseline-rev origin/main --release-type minor --only-explicit-features --features default - run_check semver_no_local cargo semver-checks --package rmcp --baseline-rev origin/main --release-type minor --only-explicit-features --features "$FEATURES" - run_check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force origin/main..HEAD - run_check public_api_no_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force origin/main..HEAD printf 'overall=%s\n' "$failed" >> final-issue-974-check.txt @@ -89,11 +81,16 @@ jobs: if: always() shell: bash run: | + touch final-issue-974-check.txt if [ "${{ steps.typos.outcome }}" = success ]; then printf 'typos=0\n' >> final-issue-974-check.txt else printf 'typos=1\n' >> final-issue-974-check.txt - sed -i 's/overall=0/overall=1/' final-issue-974-check.txt + if grep -q '^overall=0$' final-issue-974-check.txt; then + sed -i 's/^overall=0$/overall=1/' final-issue-974-check.txt + else + printf 'overall=1\n' >> final-issue-974-check.txt + fi fi - name: Commit result @@ -101,6 +98,7 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com + touch final-issue-974-check.txt git add final-issue-974-check.txt git commit -m "test: record final issue 974 compliance check" || true git push origin HEAD:feat/issue-974-client-response-cache From d727c8ee02b1364c3a0650381eca6bdb8b57939c Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:28:30 +0100 Subject: [PATCH 22/48] chore: remove temporary compliance workflow --- .github/workflows/final-issue-974-check.yml | 104 -------------------- 1 file changed, 104 deletions(-) delete mode 100644 .github/workflows/final-issue-974-check.yml diff --git a/.github/workflows/final-issue-974-check.yml b/.github/workflows/final-issue-974-check.yml deleted file mode 100644 index 211ea4ac..00000000 --- a/.github/workflows/final-issue-974-check.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Final issue 974 compliance check - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/final-issue-974-check.yml - -permissions: - contents: write - -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install toolchains - run: | - rustup toolchain install stable --component clippy - rustup toolchain install nightly --component rustfmt - - - name: Run checks and record results - shell: bash - run: | - set +e - : > final-issue-974-check.txt - failed=0 - - run_check() { - name="$1" - shift - log="/tmp/${name}.log" - "$@" >"$log" 2>&1 - status=$? - printf '%s=%s\n' "$name" "$status" >> final-issue-974-check.txt - if [ "$status" -ne 0 ]; then - failed=1 - printf '\n--- %s log ---\n' "$name" >> final-issue-974-check.txt - tail -n 250 "$log" >> final-issue-974-check.txt - printf '\n--- end %s log ---\n' "$name" >> final-issue-974-check.txt - fi - } - - FEATURES=$(python - <<'PY' - import tomllib - with open('crates/rmcp/Cargo.toml', 'rb') as file: - features = tomllib.load(file)['features'] - print(','.join(sorted( - name for name in features - if name != 'local' and not name.startswith('__') - ))) - PY - ) - printf 'features=%s\n' "$FEATURES" >> final-issue-974-check.txt - test -n "$FEATURES" || failed=1 - - run_check nightly_fmt cargo +nightly fmt --all -- --check - run_check whitespace git diff --check - run_check rmcp_check cargo check -p rmcp --all-targets --all-features - run_check clippy cargo clippy --all-targets --all-features -- -D warnings - run_check all_feature_tests cargo test --all-features - run_check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture - run_check no_local_tests cargo test -p rmcp --features "$FEATURES" - run_check docs env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - run_check conformance_build cargo build -p mcp-conformance - - printf 'overall=%s\n' "$failed" >> final-issue-974-check.txt - - - name: Run spelling check - id: typos - continue-on-error: true - uses: crate-ci/typos@master - - - name: Record spelling result - if: always() - shell: bash - run: | - touch final-issue-974-check.txt - if [ "${{ steps.typos.outcome }}" = success ]; then - printf 'typos=0\n' >> final-issue-974-check.txt - else - printf 'typos=1\n' >> final-issue-974-check.txt - if grep -q '^overall=0$' final-issue-974-check.txt; then - sed -i 's/^overall=0$/overall=1/' final-issue-974-check.txt - else - printf 'overall=1\n' >> final-issue-974-check.txt - fi - fi - - - name: Commit result - if: always() - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - touch final-issue-974-check.txt - git add final-issue-974-check.txt - git commit -m "test: record final issue 974 compliance check" || true - git push origin HEAD:feat/issue-974-client-response-cache From 4111e9d41ab0207b17435c1b6f27aeb1c0f963c1 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:41:41 +0100 Subject: [PATCH 23/48] fix(client): re-export cache configuration --- crates/rmcp/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 022514c9..e70795dc 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -19,7 +19,9 @@ pub use handler::server::wrapper::Json; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; #[cfg(feature = "client")] -pub use service::{RoleClient, serve_client}; +pub use service::{ + ClientCacheConfig, MAX_CLIENT_CACHE_TTL, RoleClient, serve_client, +}; #[cfg(feature = "server")] pub use service::{RoleServer, serve_server}; From ab826cc9e54a4e0c1518b8e7a747c8ecaa467fe2 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:42:49 +0100 Subject: [PATCH 24/48] ci: run final issue 974 verification --- .github/workflows/final-verify-issue-974.yml | 172 +++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/final-verify-issue-974.yml diff --git a/.github/workflows/final-verify-issue-974.yml b/.github/workflows/final-verify-issue-974.yml new file mode 100644 index 00000000..55b13cd9 --- /dev/null +++ b/.github/workflows/final-verify-issue-974.yml @@ -0,0 +1,172 @@ +name: Final verification for issue 974 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/final-verify-issue-974.yml + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Fetch current upstream main + run: | + git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git + git fetch upstream main + test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, llvm-tools-preview + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: | + uv python install + uv venv + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-semver-checks + uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks + + - name: Install cargo-public-api + uses: taiki-e/install-action@v2 + with: + tool: cargo-public-api + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + + - name: Install commitlint + run: | + npm install --global @commitlint/cli @commitlint/config-conventional + echo "module.exports = {extends: ['@commitlint/config-conventional']}" > /tmp/commitlint.config.js + + - name: Run repository and acceptance checks + id: validation + shell: bash + run: | + set +e + : > /tmp/issue-974-validation.txt + failed=0 + + run_check() { + name="$1" + shift + echo "==> $name" + "$@" + status=$? + if [ "$status" -eq 0 ]; then + echo "$name=success" | tee -a /tmp/issue-974-validation.txt + else + echo "$name=failed($status)" | tee -a /tmp/issue-974-validation.txt + failed=1 + fi + } + + run_check nightly_format cargo +nightly fmt --all + run_check nightly_format_check cargo +nightly fmt --all -- --check + run_check whitespace git diff --check + run_check workspace_check cargo check --workspace --all-targets --all-features + run_check clippy cargo clippy --all-targets --all-features -- -D warnings + run_check default_member_tests cargo test --all-features + + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + run_check no_local_tests cargo test -p rmcp --features "$FEATURES" + run_check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + run_check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + run_check doctests cargo test -p rmcp --doc --all-features + run_check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + run_check conformance_tests cargo test --manifest-path conformance/Cargo.toml --all-features + + for dir in examples/*/; do + if [ -f "$dir/Cargo.toml" ]; then + if [[ "$dir" == *"wasi"* ]]; then + rustup target add wasm32-wasip2 + run_check "example_build_${dir//\//_}" cargo build --manifest-path "$dir/Cargo.toml" --target wasm32-wasip2 + else + run_check "example_build_${dir//\//_}" cargo build --manifest-path "$dir/Cargo.toml" --all-features --tests + run_check "example_test_${dir//\//_}" cargo test --manifest-path "$dir/Cargo.toml" --all-features --all-targets + fi + fi + done + + run_check semver_default cargo semver-checks \ + --package rmcp \ + --baseline-rev upstream/main \ + --release-type minor \ + --only-explicit-features \ + --features default + run_check semver_non_local cargo semver-checks \ + --package rmcp \ + --baseline-rev upstream/main \ + --release-type minor \ + --only-explicit-features \ + --features "$FEATURES" + run_check public_api_default cargo public-api \ + --package rmcp -ss diff --deny changed --deny removed --force \ + upstream/main..HEAD + run_check public_api_non_local cargo public-api \ + --package rmcp --features "$FEATURES" -ss diff \ + --deny changed --deny removed --force upstream/main..HEAD + run_check coverage cargo llvm-cov -p rmcp --all-features --no-report + + if [ "$failed" -ne 0 ]; then + cp /tmp/issue-974-validation.txt .issue-974-validation-result.txt + git config user.name "starsaintf" + git config user.email "52841317+starsaintf@users.noreply.github.com" + git add .issue-974-validation-result.txt + git commit -m "ci: record issue 974 validation failure" || true + git push origin HEAD:feat/issue-974-client-response-cache || true + exit 1 + fi + + - name: Check spelling + uses: crate-ci/typos@master + + - name: Squash to one reviewable commit + run: | + rm .github/workflows/final-verify-issue-974.yml + rm -f .issue-974-validation-result.txt + git reset --soft upstream/main + git add -A + git config user.name "starsaintf" + git config user.email "52841317+starsaintf@users.noreply.github.com" + git commit \ + -m "feat(client): honor SEP-2549 cache hints" \ + -m "Add configurable TTL-aware caching for cacheable client responses, authorization-context partitioning for private entries, pagination and notification invalidation, documentation, and regression coverage." + npx commitlint --config /tmp/commitlint.config.js --from upstream/main --to HEAD --verbose + git push --force-with-lease origin HEAD:feat/issue-974-client-response-cache From 8a00d8e04ee6679cfade01e89d2d74f39754b9e9 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:53:12 +0100 Subject: [PATCH 25/48] ci: refine and validate issue 974 implementation --- .github/workflows/refine-issue-974.yml | 616 +++++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 .github/workflows/refine-issue-974.yml diff --git a/.github/workflows/refine-issue-974.yml b/.github/workflows/refine-issue-974.yml new file mode 100644 index 00000000..a3c2b861 --- /dev/null +++ b/.github/workflows/refine-issue-974.yml @@ -0,0 +1,616 @@ +name: Refine and validate issue 974 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/refine-issue-974.yml + +permissions: + contents: write + +jobs: + refine: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Apply cache refinements + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") + file.write_text(text.replace(old, new, 1)) + + replace_once( + "crates/rmcp/src/lib.rs", + '#[cfg(feature = "client")]\npub use service::{RoleClient, serve_client};', + '#[cfg(feature = "client")]\npub use service::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL, RoleClient, serve_client};', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = serde_json::to_string(&cursor) + .expect("serializing an optional pagination cursor cannot fail"); + format!("{prefix}{cursor}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + Some(resource_read_cache_key_for_uri(¶ms.uri)) + } + + fn resource_read_cache_key_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") + }''', + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let params = serde_json::to_string(params) + .expect("serializing pagination request parameters cannot fail"); + format!("{prefix}{params}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + let meta = serde_json::to_string(¶ms.meta) + .expect("serializing resource request metadata cannot fail"); + Some(format!( + "{}{meta}", + resource_read_cache_prefix_for_uri(¶ms.uri) + )) + } + + fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) + .await; + }''', + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn max_ttl_caps_server_hint() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + }''', + ''' #[tokio::test] + async fn max_ttl_caps_server_hint() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn changing_ttl_policy_invalidates_existing_entries() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + }''', + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + } + + #[test] + fn result_affecting_metadata_is_part_of_cache_keys() { + let mut first_meta = crate::model::Meta::new(); + first_meta.insert("variant".into(), serde_json::json!("a")); + let mut second_meta = crate::model::Meta::new(); + second_meta.insert("variant".into(), serde_json::json!("b")); + + let first_page = Some(PaginatedRequestParams { + meta: Some(first_meta.clone()), + cursor: Some("page".into()), + }); + let second_page = Some(PaginatedRequestParams { + meta: Some(second_meta.clone()), + cursor: Some("page".into()), + }); + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) + ); + + let first_resource = + ReadResourceRequestParams::new("file:///example").with_meta(first_meta); + let second_resource = + ReadResourceRequestParams::new("file:///example").with_meta(second_meta); + assert_ne!( + resource_read_cache_key(&first_resource), + resource_read_cache_key(&second_resource) + ); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn resource_update_invalidates_only_the_matching_uri() { + let peer = disconnected_peer(); + let first_key = resource_read_cache_key_for_uri("file:///first"); + let second_key = resource_read_cache_key_for_uri("file:///second"); + for key in [&first_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ''' #[tokio::test] + async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { + let peer = disconnected_peer(); + let first_plain = ReadResourceRequestParams::new("file:///first"); + let mut meta = crate::model::Meta::new(); + meta.insert("variant".into(), serde_json::json!("a")); + let first_with_meta = + ReadResourceRequestParams::new("file:///first").with_meta(meta); + let second = ReadResourceRequestParams::new("file:///second"); + let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); + let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); + let second_key = resource_read_cache_key(&second).unwrap(); + for key in [&first_plain_key, &first_meta_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_plain_key).await.is_none()); + assert!(peer.cached_response(&first_meta_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn entry_limit_evicts_the_oldest_response() { + let peer = disconnected_peer(); + peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) + .await; + let first = resource_read_cache_key_for_uri("file:///first"); + let second = resource_read_cache_key_for_uri("file:///second"); + peer.cache_response( + first.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + tokio::time::sleep(Duration::from_millis(1)).await; + peer.cache_response( + second.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + + assert!(peer.cached_response(&first).await.is_none()); + assert!(peer.cached_response(&second).await.is_some()); + }''', + ''' #[tokio::test] + async fn entry_limit_only_evicts_the_oldest_resource_response() { + let peer = disconnected_peer(); + peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) + .await; + let list_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) + .unwrap(); + let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) + .unwrap(); + peer.cache_response( + list_key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + peer.cache_response( + first.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + tokio::time::sleep(Duration::from_millis(1)).await; + peer.cache_response( + second.clone(), + ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + + assert!(peer.cached_response(&list_key).await.is_some()); + assert!(peer.cached_response(&first).await.is_none()); + assert!(peer.cached_response(&second).await.is_some()); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + 'use super::RoleClient;', + 'use super::{RESOURCE_READ_CACHE_PREFIX, RoleClient};', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' /// Maximum number of responses retained by the in-memory cache. + /// + /// A value of zero disables the size limit. + pub max_entries: usize,''', + ''' /// Maximum number of `resources/read` responses retained by the in-memory cache. + /// + /// Bounded list entries are exempt from this limit. A value of zero disables + /// the size limit. + pub max_entries: usize,''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + '''impl PeerResponseCacheState { + fn trim_to_limit(&mut self) { + while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { + let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + self.entries.remove(&oldest_key); + } + } + }''', + '''impl PeerResponseCacheState { + fn is_capped_key(&self, key: &CacheKey) -> bool { + key.logical_key.starts_with(RESOURCE_READ_CACHE_PREFIX) + } + + fn capped_len(&self) -> usize { + self.entries + .keys() + .filter(|key| self.is_capped_key(key)) + .count() + } + + fn oldest_capped_key(&self) -> Option { + self.entries + .iter() + .filter(|(key, _)| self.is_capped_key(key)) + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + } + + fn trim_to_limit(&mut self) { + while self.config.max_entries > 0 + && self.capped_len() > self.config.max_entries + { + let Some(oldest_key) = self.oldest_capped_key() else { + break; + }; + self.entries.remove(&oldest_key); + } + } + }''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' if cache.config.max_entries > 0 + && !cache.entries.contains_key(&target_key) + && cache.entries.len() >= cache.config.max_entries + && let Some(oldest_key) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + { + cache.entries.remove(&oldest_key); + }''', + ''' if cache.config.max_entries > 0 + && cache.is_capped_key(&target_key) + && !cache.entries.contains_key(&target_key) + && cache.capped_len() >= cache.config.max_entries + && let Some(oldest_key) = cache.oldest_capped_key() + { + cache.entries.remove(&oldest_key); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ) + + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' trigger_disable: Arc, + trigger_enable: Arc,''', + ''' trigger_disable: Arc, + trigger_enable: Arc, + list_count: Arc,''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()),''', + ''' trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()), + list_count: Arc::new(AtomicUsize::new(0)),''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + })''', + ''' self.list_count.fetch_add(1, Ordering::SeqCst); + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + } + .with_ttl_ms(60_000) + .with_cache_scope(CacheScope::Public))''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone();''', + ''' let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone(); + let list_count = server.list_count.clone();''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + let cached_tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(cached_tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + trigger_disable.notify_one();''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b");''', + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + assert_eq!(list_count.load(Ordering::SeqCst), 2);''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 3); + + client_service.cancel().await.unwrap();''', + ) + + replace_once( + "docs/CLIENT_CACHING.md", + '''`with_max_entries()` bounds the in-memory store; the default is 512 entries and + a value of zero removes the limit.''', + '''`with_max_entries()` bounds the unbounded `resources/read` keyspace; the default + is 512 entries and a value of zero removes the limit. The bounded list caches are + exempt so a large resource working set cannot evict them.''', + ) + replace_once( + "docs/CLIENT_CACHING.md", + '''Cache keys include the method and result-affecting parameters: the cursor for + paginated list methods and the URI for resource reads. MRTR retries containing + `inputResponses` or `requestState` are never cached.''', + '''Cache keys include the method and all currently result-affecting parameters: the + cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource + reads. MRTR retries containing `inputResponses` or `requestState` are never cached. + A response that omits `cacheScope` is treated as private rather than made shareable.''', + ) + PY + + - name: Format with repository toolchain + run: cargo +nightly fmt --all + + - name: Verify formatting and whitespace + run: | + cargo +nightly fmt --all -- --check + git diff --check + + - name: Run focused cache tests + run: | + cargo test -p rmcp --all-features service::client::tests -- --nocapture + cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + + - name: Run repository Clippy command + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run repository test command + run: cargo test --all-features + + - name: Commit refinements + run: | + rm .github/workflows/refine-issue-974.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): harden response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From 4dc0f72d30e7351d88c6d5f6e54f5eb546737322 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:57:11 +0100 Subject: [PATCH 26/48] ci: replace failed issue 974 refinement harness --- .github/workflows/refine-issue-974.yml | 616 ------------------------- 1 file changed, 616 deletions(-) delete mode 100644 .github/workflows/refine-issue-974.yml diff --git a/.github/workflows/refine-issue-974.yml b/.github/workflows/refine-issue-974.yml deleted file mode 100644 index a3c2b861..00000000 --- a/.github/workflows/refine-issue-974.yml +++ /dev/null @@ -1,616 +0,0 @@ -name: Refine and validate issue 974 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/refine-issue-974.yml - -permissions: - contents: write - -jobs: - refine: - runs-on: ubuntu-latest - timeout-minutes: 90 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Apply cache refinements - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") - file.write_text(text.replace(old, new, 1)) - - replace_once( - "crates/rmcp/src/lib.rs", - '#[cfg(feature = "client")]\npub use service::{RoleClient, serve_client};', - '#[cfg(feature = "client")]\npub use service::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL, RoleClient, serve_client};', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = serde_json::to_string(&cursor) - .expect("serializing an optional pagination cursor cannot fail"); - format!("{prefix}{cursor}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - Some(resource_read_cache_key_for_uri(¶ms.uri)) - } - - fn resource_read_cache_key_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") - }''', - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let params = serde_json::to_string(params) - .expect("serializing pagination request parameters cannot fail"); - format!("{prefix}{params}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - let meta = serde_json::to_string(¶ms.meta) - .expect("serializing resource request metadata cannot fail"); - Some(format!( - "{}{meta}", - resource_read_cache_prefix_for_uri(¶ms.uri) - )) - } - - fn resource_read_cache_prefix_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) - .await; - }''', - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) - .await; - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn max_ttl_caps_server_hint() { - let peer = disconnected_peer(); - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - let params = None::; - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - peer.cache_response( - key, - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - tokio::time::sleep(Duration::from_millis(5)).await; - - assert!(matches!( - peer.list_tools(params).await, - Err(ServiceError::TransportClosed) - )); - }''', - ''' #[tokio::test] - async fn max_ttl_caps_server_hint() { - let peer = disconnected_peer(); - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - let params = None::; - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - peer.cache_response( - key, - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - tokio::time::sleep(Duration::from_millis(5)).await; - - assert!(matches!( - peer.list_tools(params).await, - Err(ServiceError::TransportClosed) - )); - } - - #[tokio::test] - async fn changing_ttl_policy_invalidates_existing_entries() { - let peer = disconnected_peer(); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - assert!(peer.cached_response(&key).await.is_some()); - - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - - assert!(peer.cached_response(&key).await.is_none()); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - }''', - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - } - - #[test] - fn result_affecting_metadata_is_part_of_cache_keys() { - let mut first_meta = crate::model::Meta::new(); - first_meta.insert("variant".into(), serde_json::json!("a")); - let mut second_meta = crate::model::Meta::new(); - second_meta.insert("variant".into(), serde_json::json!("b")); - - let first_page = Some(PaginatedRequestParams { - meta: Some(first_meta.clone()), - cursor: Some("page".into()), - }); - let second_page = Some(PaginatedRequestParams { - meta: Some(second_meta.clone()), - cursor: Some("page".into()), - }); - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) - ); - - let first_resource = - ReadResourceRequestParams::new("file:///example").with_meta(first_meta); - let second_resource = - ReadResourceRequestParams::new("file:///example").with_meta(second_meta); - assert_ne!( - resource_read_cache_key(&first_resource), - resource_read_cache_key(&second_resource) - ); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn resource_update_invalidates_only_the_matching_uri() { - let peer = disconnected_peer(); - let first_key = resource_read_cache_key_for_uri("file:///first"); - let second_key = resource_read_cache_key_for_uri("file:///second"); - for key in [&first_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ''' #[tokio::test] - async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { - let peer = disconnected_peer(); - let first_plain = ReadResourceRequestParams::new("file:///first"); - let mut meta = crate::model::Meta::new(); - meta.insert("variant".into(), serde_json::json!("a")); - let first_with_meta = - ReadResourceRequestParams::new("file:///first").with_meta(meta); - let second = ReadResourceRequestParams::new("file:///second"); - let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); - let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); - let second_key = resource_read_cache_key(&second).unwrap(); - for key in [&first_plain_key, &first_meta_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_plain_key).await.is_none()); - assert!(peer.cached_response(&first_meta_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn entry_limit_evicts_the_oldest_response() { - let peer = disconnected_peer(); - peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) - .await; - let first = resource_read_cache_key_for_uri("file:///first"); - let second = resource_read_cache_key_for_uri("file:///second"); - peer.cache_response( - first.clone(), - ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - tokio::time::sleep(Duration::from_millis(1)).await; - peer.cache_response( - second.clone(), - ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - - assert!(peer.cached_response(&first).await.is_none()); - assert!(peer.cached_response(&second).await.is_some()); - }''', - ''' #[tokio::test] - async fn entry_limit_only_evicts_the_oldest_resource_response() { - let peer = disconnected_peer(); - peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) - .await; - let list_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) - .unwrap(); - let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) - .unwrap(); - peer.cache_response( - list_key.clone(), - ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), - Some(5_000), - Some(CacheScope::Public), - ) - .await; - peer.cache_response( - first.clone(), - ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - tokio::time::sleep(Duration::from_millis(1)).await; - peer.cache_response( - second.clone(), - ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - - assert!(peer.cached_response(&list_key).await.is_some()); - assert!(peer.cached_response(&first).await.is_none()); - assert!(peer.cached_response(&second).await.is_some()); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - 'use super::RoleClient;', - 'use super::{RESOURCE_READ_CACHE_PREFIX, RoleClient};', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' /// Maximum number of responses retained by the in-memory cache. - /// - /// A value of zero disables the size limit. - pub max_entries: usize,''', - ''' /// Maximum number of `resources/read` responses retained by the in-memory cache. - /// - /// Bounded list entries are exempt from this limit. A value of zero disables - /// the size limit. - pub max_entries: usize,''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - '''impl PeerResponseCacheState { - fn trim_to_limit(&mut self) { - while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { - let Some(oldest_key) = self - .entries - .iter() - .min_by_key(|(_, entry)| entry.inserted_at) - .map(|(key, _)| key.clone()) - else { - break; - }; - self.entries.remove(&oldest_key); - } - } - }''', - '''impl PeerResponseCacheState { - fn is_capped_key(&self, key: &CacheKey) -> bool { - key.logical_key.starts_with(RESOURCE_READ_CACHE_PREFIX) - } - - fn capped_len(&self) -> usize { - self.entries - .keys() - .filter(|key| self.is_capped_key(key)) - .count() - } - - fn oldest_capped_key(&self) -> Option { - self.entries - .iter() - .filter(|(key, _)| self.is_capped_key(key)) - .min_by_key(|(_, entry)| entry.inserted_at) - .map(|(key, _)| key.clone()) - } - - fn trim_to_limit(&mut self) { - while self.config.max_entries > 0 - && self.capped_len() > self.config.max_entries - { - let Some(oldest_key) = self.oldest_capped_key() else { - break; - }; - self.entries.remove(&oldest_key); - } - } - }''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' if cache.config.max_entries > 0 - && !cache.entries.contains_key(&target_key) - && cache.entries.len() >= cache.config.max_entries - && let Some(oldest_key) = cache - .entries - .iter() - .min_by_key(|(_, entry)| entry.inserted_at) - .map(|(key, _)| key.clone()) - { - cache.entries.remove(&oldest_key); - }''', - ''' if cache.config.max_entries > 0 - && cache.is_capped_key(&target_key) - && !cache.entries.contains_key(&target_key) - && cache.capped_len() >= cache.config.max_entries - && let Some(oldest_key) = cache.oldest_capped_key() - { - cache.entries.remove(&oldest_key); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - let ttl_policy_changed = cache.config.default_ttl != config.default_ttl - || cache.config.max_ttl != config.max_ttl; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled || ttl_policy_changed { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ) - - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' trigger_disable: Arc, - trigger_enable: Arc,''', - ''' trigger_disable: Arc, - trigger_enable: Arc, - list_count: Arc,''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' trigger_disable: Arc::new(Notify::new()), - trigger_enable: Arc::new(Notify::new()),''', - ''' trigger_disable: Arc::new(Notify::new()), - trigger_enable: Arc::new(Notify::new()), - list_count: Arc::new(AtomicUsize::new(0)),''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - })''', - ''' self.list_count.fetch_add(1, Ordering::SeqCst); - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - } - .with_ttl_ms(60_000) - .with_cache_scope(CacheScope::Public))''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let trigger_disable = server.trigger_disable.clone(); - let trigger_enable = server.trigger_enable.clone();''', - ''' let trigger_disable = server.trigger_disable.clone(); - let trigger_enable = server.trigger_enable.clone(); - let list_count = server.list_count.clone();''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - trigger_disable.notify_one();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - let cached_tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(cached_tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - trigger_disable.notify_one();''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b");''', - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b"); - assert_eq!(list_count.load(Ordering::SeqCst), 2);''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - client_service.cancel().await.unwrap();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 3); - - client_service.cancel().await.unwrap();''', - ) - - replace_once( - "docs/CLIENT_CACHING.md", - '''`with_max_entries()` bounds the in-memory store; the default is 512 entries and - a value of zero removes the limit.''', - '''`with_max_entries()` bounds the unbounded `resources/read` keyspace; the default - is 512 entries and a value of zero removes the limit. The bounded list caches are - exempt so a large resource working set cannot evict them.''', - ) - replace_once( - "docs/CLIENT_CACHING.md", - '''Cache keys include the method and result-affecting parameters: the cursor for - paginated list methods and the URI for resource reads. MRTR retries containing - `inputResponses` or `requestState` are never cached.''', - '''Cache keys include the method and all currently result-affecting parameters: the - cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource - reads. MRTR retries containing `inputResponses` or `requestState` are never cached. - A response that omits `cacheScope` is treated as private rather than made shareable.''', - ) - PY - - - name: Format with repository toolchain - run: cargo +nightly fmt --all - - - name: Verify formatting and whitespace - run: | - cargo +nightly fmt --all -- --check - git diff --check - - - name: Run focused cache tests - run: | - cargo test -p rmcp --all-features service::client::tests -- --nocapture - cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - - - name: Run repository Clippy command - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run repository test command - run: cargo test --all-features - - - name: Commit refinements - run: | - rm .github/workflows/refine-issue-974.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): harden response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache From 0637bd45c10a8193c1fab44b7b5664c3016bb11f Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:00:26 +0100 Subject: [PATCH 27/48] ci: refine and fully verify issue 974 --- .github/workflows/final-verify-issue-974.yml | 401 ++++++++++++++++++- 1 file changed, 387 insertions(+), 14 deletions(-) diff --git a/.github/workflows/final-verify-issue-974.yml b/.github/workflows/final-verify-issue-974.yml index 55b13cd9..c51e3c2e 100644 --- a/.github/workflows/final-verify-issue-974.yml +++ b/.github/workflows/final-verify-issue-974.yml @@ -72,6 +72,385 @@ jobs: npm install --global @commitlint/cli @commitlint/config-conventional echo "module.exports = {extends: ['@commitlint/config-conventional']}" > /tmp/commitlint.config.js + - name: Apply final cache refinements + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") + file.write_text(text.replace(old, new, 1)) + + replace_once( + "crates/rmcp/src/service/client.rs", + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = serde_json::to_string(&cursor) + .expect("serializing an optional pagination cursor cannot fail"); + format!("{prefix}{cursor}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + Some(resource_read_cache_key_for_uri(¶ms.uri)) + } + + fn resource_read_cache_key_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") + }''', + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let params = serde_json::to_string(params) + .expect("serializing pagination request parameters cannot fail"); + format!("{prefix}{params}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + let params = serde_json::to_string(params) + .expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{params}", + resource_read_cache_prefix_for_uri(¶ms_uri(params.as_str())) + )) + } + + fn params_uri(_serialized_params: &str) -> &str { + unreachable!("resource cache keys use the request URI before serialization") + } + + fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") + }''', + ) + + # Correct the temporary helper above into the straightforward URI-prefix form while + # still serializing the complete non-MRTR request parameters. + replace_once( + "crates/rmcp/src/service/client.rs", + ''' let params = serde_json::to_string(params) + .expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{params}", + resource_read_cache_prefix_for_uri(¶ms_uri(params.as_str())) + )) + } + + fn params_uri(_serialized_params: &str) -> &str { + unreachable!("resource cache keys use the request URI before serialization") + }''', + ''' let serialized = serde_json::to_string(params) + .expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{serialized}", + resource_read_cache_prefix_for_uri(¶ms.uri) + )) + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) + .await; + }''', + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + }''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + }''', + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + } + + #[test] + fn result_affecting_metadata_is_part_of_cache_keys() { + let mut first_meta = crate::model::Meta::new(); + first_meta.insert("variant".into(), serde_json::json!("a")); + let mut second_meta = crate::model::Meta::new(); + second_meta.insert("variant".into(), serde_json::json!("b")); + + let first_page = Some(PaginatedRequestParams { + meta: Some(first_meta.clone()), + cursor: Some("page".into()), + }); + let second_page = Some(PaginatedRequestParams { + meta: Some(second_meta.clone()), + cursor: Some("page".into()), + }); + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) + ); + + let first_resource = + ReadResourceRequestParams::new("file:///example").with_meta(first_meta); + let second_resource = + ReadResourceRequestParams::new("file:///example").with_meta(second_meta); + assert_ne!( + resource_read_cache_key(&first_resource), + resource_read_cache_key(&second_resource) + ); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn resource_update_invalidates_only_the_matching_uri() { + let peer = disconnected_peer(); + let first_key = resource_read_cache_key_for_uri("file:///first"); + let second_key = resource_read_cache_key_for_uri("file:///second"); + for key in [&first_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ''' #[tokio::test] + async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { + let peer = disconnected_peer(); + let first_plain = ReadResourceRequestParams::new("file:///first"); + let mut meta = crate::model::Meta::new(); + meta.insert("variant".into(), serde_json::json!("a")); + let first_with_meta = + ReadResourceRequestParams::new("file:///first").with_meta(meta); + let second = ReadResourceRequestParams::new("file:///second"); + let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); + let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); + let second_key = resource_read_cache_key(&second).unwrap(); + for key in [&first_plain_key, &first_meta_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_plain_key).await.is_none()); + assert!(peer.cached_response(&first_meta_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' let first = resource_read_cache_key_for_uri("file:///first"); + let second = resource_read_cache_key_for_uri("file:///second");''', + ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) + .unwrap(); + let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) + .unwrap();''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', + ''' #[tokio::test] + async fn changing_ttl_policy_invalidates_existing_entries() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', + ) + + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' trigger_disable: Arc, + trigger_enable: Arc,''', + ''' trigger_disable: Arc, + trigger_enable: Arc, + list_count: Arc,''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()),''', + ''' trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()), + list_count: Arc::new(AtomicUsize::new(0)),''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + })''', + ''' self.list_count.fetch_add(1, Ordering::SeqCst); + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + } + .with_ttl_ms(60_000) + .with_cache_scope(CacheScope::Public))''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone();''', + ''' let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone(); + let list_count = server.list_count.clone();''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + let cached_tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(cached_tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + trigger_disable.notify_one();''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b");''', + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + assert_eq!(list_count.load(Ordering::SeqCst), 2);''', + ) + replace_once( + "crates/rmcp/tests/test_tool_disable_notification.rs", + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 3); + + client_service.cancel().await.unwrap();''', + ) + + replace_once( + "docs/CLIENT_CACHING.md", + '''Cache keys include the method and result-affecting parameters: the cursor for + paginated list methods and the URI for resource reads. MRTR retries containing + `inputResponses` or `requestState` are never cached.''', + '''Cache keys include the method and all currently result-affecting parameters: the + cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource + reads. MRTR retries containing `inputResponses` or `requestState` are never cached. + A response that omits `cacheScope` is treated as private rather than made shareable.''', + ) + PY + - name: Run repository and acceptance checks id: validation shell: bash @@ -107,6 +486,7 @@ jobs: | select(. != "local")] | join(",")') run_check no_local_tests cargo test -p rmcp --features "$FEATURES" run_check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + run_check cache_integration_test cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture run_check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture run_check doctests cargo test -p rmcp --doc --all-features run_check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps @@ -125,20 +505,13 @@ jobs: done run_check semver_default cargo semver-checks \ - --package rmcp \ - --baseline-rev upstream/main \ - --release-type minor \ - --only-explicit-features \ - --features default + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features default run_check semver_non_local cargo semver-checks \ - --package rmcp \ - --baseline-rev upstream/main \ - --release-type minor \ - --only-explicit-features \ - --features "$FEATURES" + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features "$FEATURES" run_check public_api_default cargo public-api \ - --package rmcp -ss diff --deny changed --deny removed --force \ - upstream/main..HEAD + --package rmcp -ss diff --deny changed --deny removed --force upstream/main..HEAD run_check public_api_non_local cargo public-api \ --package rmcp --features "$FEATURES" -ss diff \ --deny changed --deny removed --force upstream/main..HEAD @@ -148,7 +521,7 @@ jobs: cp /tmp/issue-974-validation.txt .issue-974-validation-result.txt git config user.name "starsaintf" git config user.email "52841317+starsaintf@users.noreply.github.com" - git add .issue-974-validation-result.txt + git add -A git commit -m "ci: record issue 974 validation failure" || true git push origin HEAD:feat/issue-974-client-response-cache || true exit 1 @@ -167,6 +540,6 @@ jobs: git config user.email "52841317+starsaintf@users.noreply.github.com" git commit \ -m "feat(client): honor SEP-2549 cache hints" \ - -m "Add configurable TTL-aware caching for cacheable client responses, authorization-context partitioning for private entries, pagination and notification invalidation, documentation, and regression coverage." + -m "Add configurable TTL-aware caching for cacheable client responses, authorization-context partitioning for private entries, complete request cache keys, pagination and notification invalidation, documentation, and regression coverage." npx commitlint --config /tmp/commitlint.config.js --from upstream/main --to HEAD --verbose git push --force-with-lease origin HEAD:feat/issue-974-client-response-cache From c95568722b61f10887817806fb3ae77814e07142 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:00:57 +0100 Subject: [PATCH 28/48] ci: apply deterministic issue 974 refinements --- .github/workflows/refine-issue-974-v2.yml | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/refine-issue-974-v2.yml diff --git a/.github/workflows/refine-issue-974-v2.yml b/.github/workflows/refine-issue-974-v2.yml new file mode 100644 index 00000000..bf489024 --- /dev/null +++ b/.github/workflows/refine-issue-974-v2.yml @@ -0,0 +1,76 @@ +name: Refine issue 974 v2 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/refine-issue-974-v2.yml + +permissions: + contents: write + +jobs: + refine: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Download and apply deterministic refinements + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ + --output /tmp/refine.py.gz.b64 + base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py + python3 /tmp/refine.py + + - name: Format with repository toolchain + run: cargo +nightly fmt --all + + - name: Verify formatting and whitespace + run: | + cargo +nightly fmt --all -- --check + git diff --check + + - name: Run focused cache tests + run: | + cargo test -p rmcp --all-features service::client::tests -- --nocapture + cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + + - name: Run repository Clippy command + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run repository test command + run: cargo test --all-features + + - name: Run tests without local feature + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + + - name: Commit verified refinements + run: | + rm .github/workflows/refine-issue-974-v2.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): harden response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From d28e1c1614c7a4cb1a58394a2d55d4d1cdf13b3f Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:05:14 +0100 Subject: [PATCH 29/48] ci: rerun issue 974 refinements with idempotent script --- .github/workflows/refine-issue-974-v2.yml | 76 ----------------------- 1 file changed, 76 deletions(-) delete mode 100644 .github/workflows/refine-issue-974-v2.yml diff --git a/.github/workflows/refine-issue-974-v2.yml b/.github/workflows/refine-issue-974-v2.yml deleted file mode 100644 index bf489024..00000000 --- a/.github/workflows/refine-issue-974-v2.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Refine issue 974 v2 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/refine-issue-974-v2.yml - -permissions: - contents: write - -jobs: - refine: - runs-on: ubuntu-latest - timeout-minutes: 90 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Download and apply deterministic refinements - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ - --output /tmp/refine.py.gz.b64 - base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py - python3 /tmp/refine.py - - - name: Format with repository toolchain - run: cargo +nightly fmt --all - - - name: Verify formatting and whitespace - run: | - cargo +nightly fmt --all -- --check - git diff --check - - - name: Run focused cache tests - run: | - cargo test -p rmcp --all-features service::client::tests -- --nocapture - cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - - - name: Run repository Clippy command - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run repository test command - run: cargo test --all-features - - - name: Run tests without local feature - run: | - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - cargo test -p rmcp --features "$FEATURES" - - - name: Commit verified refinements - run: | - rm .github/workflows/refine-issue-974-v2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): harden response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache From 7da782bea1598c25c18e82f238afff12f9c69854 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:05:35 +0100 Subject: [PATCH 30/48] ci: run final issue 974 refinement pass --- .github/workflows/refine-issue-974-v3.yml | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/refine-issue-974-v3.yml diff --git a/.github/workflows/refine-issue-974-v3.yml b/.github/workflows/refine-issue-974-v3.yml new file mode 100644 index 00000000..12402de8 --- /dev/null +++ b/.github/workflows/refine-issue-974-v3.yml @@ -0,0 +1,76 @@ +name: Refine issue 974 v3 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/refine-issue-974-v3.yml + +permissions: + contents: write + +jobs: + refine: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Download and apply deterministic refinements + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ + --output /tmp/refine.py.gz.b64 + base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py + python3 /tmp/refine.py + + - name: Format with repository toolchain + run: cargo +nightly fmt --all + + - name: Verify formatting and whitespace + run: | + cargo +nightly fmt --all -- --check + git diff --check + + - name: Run focused cache tests + run: | + cargo test -p rmcp --all-features service::client::tests -- --nocapture + cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + + - name: Run repository Clippy command + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run repository test command + run: cargo test --all-features + + - name: Run tests without local feature + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + + - name: Commit verified refinements + run: | + rm .github/workflows/refine-issue-974-v3.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): harden response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From 4c4ae2e5e853a3902e708caaf73fe8e42bb7ce69 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:09:19 +0100 Subject: [PATCH 31/48] ci: remove competing issue 974 refinement --- .github/workflows/refine-issue-974-v3.yml | 76 ----------------------- 1 file changed, 76 deletions(-) delete mode 100644 .github/workflows/refine-issue-974-v3.yml diff --git a/.github/workflows/refine-issue-974-v3.yml b/.github/workflows/refine-issue-974-v3.yml deleted file mode 100644 index 12402de8..00000000 --- a/.github/workflows/refine-issue-974-v3.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Refine issue 974 v3 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/refine-issue-974-v3.yml - -permissions: - contents: write - -jobs: - refine: - runs-on: ubuntu-latest - timeout-minutes: 90 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Download and apply deterministic refinements - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ - --output /tmp/refine.py.gz.b64 - base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py - python3 /tmp/refine.py - - - name: Format with repository toolchain - run: cargo +nightly fmt --all - - - name: Verify formatting and whitespace - run: | - cargo +nightly fmt --all -- --check - git diff --check - - - name: Run focused cache tests - run: | - cargo test -p rmcp --all-features service::client::tests -- --nocapture - cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - - - name: Run repository Clippy command - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run repository test command - run: cargo test --all-features - - - name: Run tests without local feature - run: | - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - cargo test -p rmcp --features "$FEATURES" - - - name: Commit verified refinements - run: | - rm .github/workflows/refine-issue-974-v3.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): harden response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache From 5e6daa2690012943c0d30324f79ca734ee0c9da0 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:12:45 +0100 Subject: [PATCH 32/48] ci: replace issue 974 verification workflow --- .github/workflows/final-verify-issue-974.yml | 545 ------------------- 1 file changed, 545 deletions(-) delete mode 100644 .github/workflows/final-verify-issue-974.yml diff --git a/.github/workflows/final-verify-issue-974.yml b/.github/workflows/final-verify-issue-974.yml deleted file mode 100644 index c51e3c2e..00000000 --- a/.github/workflows/final-verify-issue-974.yml +++ /dev/null @@ -1,545 +0,0 @@ -name: Final verification for issue 974 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/final-verify-issue-974.yml - -permissions: - contents: write - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 120 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Fetch current upstream main - run: | - git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git - git fetch upstream main - test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, llvm-tools-preview - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Set up Python - run: | - uv python install - uv venv - - - name: Cache Rust build - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks - - - name: Install cargo-public-api - uses: taiki-e/install-action@v2 - with: - tool: cargo-public-api - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov - - - name: Install commitlint - run: | - npm install --global @commitlint/cli @commitlint/config-conventional - echo "module.exports = {extends: ['@commitlint/config-conventional']}" > /tmp/commitlint.config.js - - - name: Apply final cache refinements - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") - file.write_text(text.replace(old, new, 1)) - - replace_once( - "crates/rmcp/src/service/client.rs", - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = serde_json::to_string(&cursor) - .expect("serializing an optional pagination cursor cannot fail"); - format!("{prefix}{cursor}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - Some(resource_read_cache_key_for_uri(¶ms.uri)) - } - - fn resource_read_cache_key_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") - }''', - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let params = serde_json::to_string(params) - .expect("serializing pagination request parameters cannot fail"); - format!("{prefix}{params}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - let params = serde_json::to_string(params) - .expect("serializing resource request parameters cannot fail"); - Some(format!( - "{}{params}", - resource_read_cache_prefix_for_uri(¶ms_uri(params.as_str())) - )) - } - - fn params_uri(_serialized_params: &str) -> &str { - unreachable!("resource cache keys use the request URI before serialization") - } - - fn resource_read_cache_prefix_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") - }''', - ) - - # Correct the temporary helper above into the straightforward URI-prefix form while - # still serializing the complete non-MRTR request parameters. - replace_once( - "crates/rmcp/src/service/client.rs", - ''' let params = serde_json::to_string(params) - .expect("serializing resource request parameters cannot fail"); - Some(format!( - "{}{params}", - resource_read_cache_prefix_for_uri(¶ms_uri(params.as_str())) - )) - } - - fn params_uri(_serialized_params: &str) -> &str { - unreachable!("resource cache keys use the request URI before serialization") - }''', - ''' let serialized = serde_json::to_string(params) - .expect("serializing resource request parameters cannot fail"); - Some(format!( - "{}{serialized}", - resource_read_cache_prefix_for_uri(¶ms.uri) - )) - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) - .await; - }''', - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) - .await; - }''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - let ttl_policy_changed = cache.config.default_ttl != config.default_ttl - || cache.config.max_ttl != config.max_ttl; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled || ttl_policy_changed { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - }''', - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - } - - #[test] - fn result_affecting_metadata_is_part_of_cache_keys() { - let mut first_meta = crate::model::Meta::new(); - first_meta.insert("variant".into(), serde_json::json!("a")); - let mut second_meta = crate::model::Meta::new(); - second_meta.insert("variant".into(), serde_json::json!("b")); - - let first_page = Some(PaginatedRequestParams { - meta: Some(first_meta.clone()), - cursor: Some("page".into()), - }); - let second_page = Some(PaginatedRequestParams { - meta: Some(second_meta.clone()), - cursor: Some("page".into()), - }); - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) - ); - - let first_resource = - ReadResourceRequestParams::new("file:///example").with_meta(first_meta); - let second_resource = - ReadResourceRequestParams::new("file:///example").with_meta(second_meta); - assert_ne!( - resource_read_cache_key(&first_resource), - resource_read_cache_key(&second_resource) - ); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn resource_update_invalidates_only_the_matching_uri() { - let peer = disconnected_peer(); - let first_key = resource_read_cache_key_for_uri("file:///first"); - let second_key = resource_read_cache_key_for_uri("file:///second"); - for key in [&first_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ''' #[tokio::test] - async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { - let peer = disconnected_peer(); - let first_plain = ReadResourceRequestParams::new("file:///first"); - let mut meta = crate::model::Meta::new(); - meta.insert("variant".into(), serde_json::json!("a")); - let first_with_meta = - ReadResourceRequestParams::new("file:///first").with_meta(meta); - let second = ReadResourceRequestParams::new("file:///second"); - let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); - let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); - let second_key = resource_read_cache_key(&second).unwrap(); - for key in [&first_plain_key, &first_meta_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_plain_key).await.is_none()); - assert!(peer.cached_response(&first_meta_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' let first = resource_read_cache_key_for_uri("file:///first"); - let second = resource_read_cache_key_for_uri("file:///second");''', - ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) - .unwrap(); - let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) - .unwrap();''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', - ''' #[tokio::test] - async fn changing_ttl_policy_invalidates_existing_entries() { - let peer = disconnected_peer(); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - assert!(peer.cached_response(&key).await.is_some()); - - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - - assert!(peer.cached_response(&key).await.is_none()); - } - - #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', - ) - - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' trigger_disable: Arc, - trigger_enable: Arc,''', - ''' trigger_disable: Arc, - trigger_enable: Arc, - list_count: Arc,''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' trigger_disable: Arc::new(Notify::new()), - trigger_enable: Arc::new(Notify::new()),''', - ''' trigger_disable: Arc::new(Notify::new()), - trigger_enable: Arc::new(Notify::new()), - list_count: Arc::new(AtomicUsize::new(0)),''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - })''', - ''' self.list_count.fetch_add(1, Ordering::SeqCst); - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - } - .with_ttl_ms(60_000) - .with_cache_scope(CacheScope::Public))''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let trigger_disable = server.trigger_disable.clone(); - let trigger_enable = server.trigger_enable.clone();''', - ''' let trigger_disable = server.trigger_disable.clone(); - let trigger_enable = server.trigger_enable.clone(); - let list_count = server.list_count.clone();''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - trigger_disable.notify_one();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - let cached_tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(cached_tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - trigger_disable.notify_one();''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b");''', - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b"); - assert_eq!(list_count.load(Ordering::SeqCst), 2);''', - ) - replace_once( - "crates/rmcp/tests/test_tool_disable_notification.rs", - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - client_service.cancel().await.unwrap();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 3); - - client_service.cancel().await.unwrap();''', - ) - - replace_once( - "docs/CLIENT_CACHING.md", - '''Cache keys include the method and result-affecting parameters: the cursor for - paginated list methods and the URI for resource reads. MRTR retries containing - `inputResponses` or `requestState` are never cached.''', - '''Cache keys include the method and all currently result-affecting parameters: the - cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource - reads. MRTR retries containing `inputResponses` or `requestState` are never cached. - A response that omits `cacheScope` is treated as private rather than made shareable.''', - ) - PY - - - name: Run repository and acceptance checks - id: validation - shell: bash - run: | - set +e - : > /tmp/issue-974-validation.txt - failed=0 - - run_check() { - name="$1" - shift - echo "==> $name" - "$@" - status=$? - if [ "$status" -eq 0 ]; then - echo "$name=success" | tee -a /tmp/issue-974-validation.txt - else - echo "$name=failed($status)" | tee -a /tmp/issue-974-validation.txt - failed=1 - fi - } - - run_check nightly_format cargo +nightly fmt --all - run_check nightly_format_check cargo +nightly fmt --all -- --check - run_check whitespace git diff --check - run_check workspace_check cargo check --workspace --all-targets --all-features - run_check clippy cargo clippy --all-targets --all-features -- -D warnings - run_check default_member_tests cargo test --all-features - - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - run_check no_local_tests cargo test -p rmcp --features "$FEATURES" - run_check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture - run_check cache_integration_test cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - run_check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - run_check doctests cargo test -p rmcp --doc --all-features - run_check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - run_check conformance_tests cargo test --manifest-path conformance/Cargo.toml --all-features - - for dir in examples/*/; do - if [ -f "$dir/Cargo.toml" ]; then - if [[ "$dir" == *"wasi"* ]]; then - rustup target add wasm32-wasip2 - run_check "example_build_${dir//\//_}" cargo build --manifest-path "$dir/Cargo.toml" --target wasm32-wasip2 - else - run_check "example_build_${dir//\//_}" cargo build --manifest-path "$dir/Cargo.toml" --all-features --tests - run_check "example_test_${dir//\//_}" cargo test --manifest-path "$dir/Cargo.toml" --all-features --all-targets - fi - fi - done - - run_check semver_default cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features default - run_check semver_non_local cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features "$FEATURES" - run_check public_api_default cargo public-api \ - --package rmcp -ss diff --deny changed --deny removed --force upstream/main..HEAD - run_check public_api_non_local cargo public-api \ - --package rmcp --features "$FEATURES" -ss diff \ - --deny changed --deny removed --force upstream/main..HEAD - run_check coverage cargo llvm-cov -p rmcp --all-features --no-report - - if [ "$failed" -ne 0 ]; then - cp /tmp/issue-974-validation.txt .issue-974-validation-result.txt - git config user.name "starsaintf" - git config user.email "52841317+starsaintf@users.noreply.github.com" - git add -A - git commit -m "ci: record issue 974 validation failure" || true - git push origin HEAD:feat/issue-974-client-response-cache || true - exit 1 - fi - - - name: Check spelling - uses: crate-ci/typos@master - - - name: Squash to one reviewable commit - run: | - rm .github/workflows/final-verify-issue-974.yml - rm -f .issue-974-validation-result.txt - git reset --soft upstream/main - git add -A - git config user.name "starsaintf" - git config user.email "52841317+starsaintf@users.noreply.github.com" - git commit \ - -m "feat(client): honor SEP-2549 cache hints" \ - -m "Add configurable TTL-aware caching for cacheable client responses, authorization-context partitioning for private entries, complete request cache keys, pagination and notification invalidation, documentation, and regression coverage." - npx commitlint --config /tmp/commitlint.config.js --from upstream/main --to HEAD --verbose - git push --force-with-lease origin HEAD:feat/issue-974-client-response-cache From f69b62f9736bb26325ff66fcccd0d54263e2a39d Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:13:47 +0100 Subject: [PATCH 33/48] ci: run final issue 974 refinements --- .github/workflows/refine-issue-974-final.yml | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/refine-issue-974-final.yml diff --git a/.github/workflows/refine-issue-974-final.yml b/.github/workflows/refine-issue-974-final.yml new file mode 100644 index 00000000..8e3acb39 --- /dev/null +++ b/.github/workflows/refine-issue-974-final.yml @@ -0,0 +1,74 @@ +name: Refine issue 974 final + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/refine-issue-974-final.yml + +permissions: + contents: write + +jobs: + refine: + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Check out issue branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Apply deterministic cache refinements + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ + --output /tmp/refine.py.gz.b64 + base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py + python3 /tmp/refine.py + + - name: Format and verify source + run: | + cargo +nightly fmt --all + cargo +nightly fmt --all -- --check + git diff --check + + - name: Run focused cache tests + run: | + cargo test -p rmcp --all-features service::client::tests -- --nocapture + cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + + - name: Run repository Clippy command + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run repository test command + run: cargo test --all-features + + - name: Run tests without local feature + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + + - name: Commit verified refinements + run: | + rm .github/workflows/refine-issue-974-final.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): harden response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From c440f5b2dde28f0a599b036bfe43a57e50e958bc Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:15:30 +0100 Subject: [PATCH 34/48] ci: run authoritative issue 974 verification --- .../verify-issue-974-authoritative.yml | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 .github/workflows/verify-issue-974-authoritative.yml diff --git a/.github/workflows/verify-issue-974-authoritative.yml b/.github/workflows/verify-issue-974-authoritative.yml new file mode 100644 index 00000000..f77dd86c --- /dev/null +++ b/.github/workflows/verify-issue-974-authoritative.yml @@ -0,0 +1,462 @@ +name: Authoritative verification for issue 974 + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/verify-issue-974-authoritative.yml + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Check out branch + uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Fetch upstream main + run: | + git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git + git fetch upstream main + test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" + + - name: Install Rust toolchains + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, llvm-tools-preview + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - name: Set up Node and Python tooling + run: | + npm install --global @commitlint/cli @commitlint/config-conventional + echo "module.exports = {extends: ['@commitlint/config-conventional']}" > /tmp/commitlint.config.js + + - name: Install repository verification tools + uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli + + - name: Apply final acceptance-criteria refinements + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") + file.write_text(text.replace(old, new, 1)) + + replace_once( + "crates/rmcp/src/service/client.rs", + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = serde_json::to_string(&cursor) + .expect("serializing an optional pagination cursor cannot fail"); + format!("{prefix}{cursor}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + Some(resource_read_cache_key_for_uri(¶ms.uri)) + } + + fn resource_read_cache_key_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") + }''', + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let params = serde_json::to_string(params) + .expect("serializing pagination request parameters cannot fail"); + format!("{prefix}{params}") + } + + fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + let serialized = serde_json::to_string(params) + .expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{serialized}", + resource_read_cache_prefix_for_uri(¶ms.uri) + )) + } + + fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) + .await; + }''', + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + }''', + ) + + replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + }''', + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + } + + #[test] + fn result_affecting_metadata_is_part_of_cache_keys() { + let mut first_meta = crate::model::Meta::new(); + first_meta.insert("variant".into(), serde_json::json!("a")); + let mut second_meta = crate::model::Meta::new(); + second_meta.insert("variant".into(), serde_json::json!("b")); + + let first_page = Some(PaginatedRequestParams { + meta: Some(first_meta.clone()), + cursor: Some("page".into()), + }); + let second_page = Some(PaginatedRequestParams { + meta: Some(second_meta.clone()), + cursor: Some("page".into()), + }); + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) + ); + + let first_resource = + ReadResourceRequestParams::new("file:///example").with_meta(first_meta); + let second_resource = + ReadResourceRequestParams::new("file:///example").with_meta(second_meta); + assert_ne!( + resource_read_cache_key(&first_resource), + resource_read_cache_key(&second_resource) + ); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn resource_update_invalidates_only_the_matching_uri() { + let peer = disconnected_peer(); + let first_key = resource_read_cache_key_for_uri("file:///first"); + let second_key = resource_read_cache_key_for_uri("file:///second"); + for key in [&first_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ''' #[tokio::test] + async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { + let peer = disconnected_peer(); + let first_plain = ReadResourceRequestParams::new("file:///first"); + let mut meta = crate::model::Meta::new(); + meta.insert("variant".into(), serde_json::json!("a")); + let first_with_meta = + ReadResourceRequestParams::new("file:///first").with_meta(meta); + let second = ReadResourceRequestParams::new("file:///second"); + let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); + let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); + let second_key = resource_read_cache_key(&second).unwrap(); + for key in [&first_plain_key, &first_meta_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_plain_key).await.is_none()); + assert!(peer.cached_response(&first_meta_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' let first = resource_read_cache_key_for_uri("file:///first"); + let second = resource_read_cache_key_for_uri("file:///second");''', + ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) + .unwrap(); + let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) + .unwrap();''', + ) + + replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', + ''' #[tokio::test] + async fn changing_ttl_policy_invalidates_existing_entries() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', + ) + + path = "crates/rmcp/tests/test_tool_disable_notification.rs" + replace_once( + path, + 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + ) + replace_once(path, ' trigger_enable: Arc,\n}', ' trigger_enable: Arc,\n list_count: Arc,\n}') + replace_once( + path, + ' trigger_enable: Arc::new(Notify::new()),\n }', + ' trigger_enable: Arc::new(Notify::new()),\n list_count: Arc::new(AtomicUsize::new(0)),\n }', + ) + replace_once( + path, + ''' let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + })''', + ''' self.list_count.fetch_add(1, Ordering::SeqCst); + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + } + .with_ttl_ms(60_000) + .with_cache_scope(CacheScope::Public))''', + ) + replace_once( + path, + ' let trigger_enable = server.trigger_enable.clone();\n', + ' let trigger_enable = server.trigger_enable.clone();\n let list_count = server.list_count.clone();\n', + ) + replace_once( + path, + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + let cached_tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(cached_tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + trigger_disable.notify_one();''', + ) + replace_once( + path, + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b");''', + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + assert_eq!(list_count.load(Ordering::SeqCst), 2);''', + ) + replace_once( + path, + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 3); + + client_service.cancel().await.unwrap();''', + ) + + replace_once( + "docs/CLIENT_CACHING.md", + '''Cache keys include the method and result-affecting parameters: the cursor for + paginated list methods and the URI for resource reads. MRTR retries containing + `inputResponses` or `requestState` are never cached.''', + '''Cache keys include the method and all currently result-affecting parameters: the + cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource + reads. MRTR retries containing `inputResponses` or `requestState` are never cached. + A response that omits `cacheScope` is treated as private rather than made shareable.''', + ) + PY + + - name: Run complete verification matrix + shell: bash + run: | + set +e + report=/tmp/issue-974-verification.txt + : > "$report" + failed=0 + + check() { + name="$1" + shift + echo "==> $name" + "$@" + status=$? + if [ "$status" -eq 0 ]; then + echo "$name=success" | tee -a "$report" + else + echo "$name=failed($status)" | tee -a "$report" + failed=1 + fi + } + + check nightly_format cargo +nightly fmt --all + check nightly_format_check cargo +nightly fmt --all -- --check + check whitespace git diff --check + check workspace_check cargo check --workspace --all-targets --all-features + check clippy cargo clippy --all-targets --all-features -- -D warnings + check all_feature_tests cargo test --all-features + + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + check no_local_tests cargo test -p rmcp --features "$FEATURES" + check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + check doctests cargo test -p rmcp --doc --all-features + check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + check conformance cargo test --manifest-path conformance/Cargo.toml --all-features + check semver_default cargo semver-checks --package rmcp --baseline-rev upstream/main --release-type minor --only-explicit-features --features default + check semver_non_local cargo semver-checks --package rmcp --baseline-rev upstream/main --release-type minor --only-explicit-features --features "$FEATURES" + check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force upstream/main..HEAD + check public_api_non_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force upstream/main..HEAD + check coverage cargo llvm-cov -p rmcp --all-features --no-report + check spelling typos + + echo "upstream_main=$(git rev-parse upstream/main)" >> "$report" + echo "fork_main=$(git rev-parse origin/main)" >> "$report" + + if [ "$failed" -ne 0 ]; then + cp "$report" /tmp/report-copy + git reset --hard HEAD + cp /tmp/report-copy .issue-974-verification-result.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .issue-974-verification-result.txt + git commit -m "ci: record issue 974 verification failure" + git push origin HEAD:feat/issue-974-client-response-cache + exit 1 + fi + + cp "$report" .issue-974-verification-result.txt + + - name: Commit verified implementation + run: | + rm .github/workflows/verify-issue-974-authoritative.yml + git config user.name "starsaintf" + git config user.email "52841317+starsaintf@users.noreply.github.com" + git add -A + git commit \ + -m "feat(client): honor SEP-2549 cache hints" \ + -m "Add configurable TTL-aware client response caching with authorization-context partitioning, complete request cache keys, pagination and notification invalidation, documentation, and regression coverage." + npx commitlint --config /tmp/commitlint.config.js --from HEAD~1 --to HEAD --verbose + git push origin HEAD:feat/issue-974-client-response-cache From d7705f6c35b1c191047fea064affc5ee271a872c Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:16:12 +0100 Subject: [PATCH 35/48] ci: remove stale issue 974 refinement workflow --- .github/workflows/refine-issue-974-final.yml | 74 -------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/refine-issue-974-final.yml diff --git a/.github/workflows/refine-issue-974-final.yml b/.github/workflows/refine-issue-974-final.yml deleted file mode 100644 index 8e3acb39..00000000 --- a/.github/workflows/refine-issue-974-final.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Refine issue 974 final - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/refine-issue-974-final.yml - -permissions: - contents: write - -jobs: - refine: - runs-on: ubuntu-latest - timeout-minutes: 120 - - steps: - - name: Check out issue branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Apply deterministic cache refinements - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ - --output /tmp/refine.py.gz.b64 - base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py - python3 /tmp/refine.py - - - name: Format and verify source - run: | - cargo +nightly fmt --all - cargo +nightly fmt --all -- --check - git diff --check - - - name: Run focused cache tests - run: | - cargo test -p rmcp --all-features service::client::tests -- --nocapture - cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - - - name: Run repository Clippy command - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run repository test command - run: cargo test --all-features - - - name: Run tests without local feature - run: | - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - cargo test -p rmcp --features "$FEATURES" - - - name: Commit verified refinements - run: | - rm .github/workflows/refine-issue-974-final.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): harden response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache From a0bee1a5a9b4cdec76094158aa8b35b04a7e99bc Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:17:59 +0100 Subject: [PATCH 36/48] ci: replace issue 974 authoritative workflow --- .../verify-issue-974-authoritative.yml | 462 ------------------ 1 file changed, 462 deletions(-) delete mode 100644 .github/workflows/verify-issue-974-authoritative.yml diff --git a/.github/workflows/verify-issue-974-authoritative.yml b/.github/workflows/verify-issue-974-authoritative.yml deleted file mode 100644 index f77dd86c..00000000 --- a/.github/workflows/verify-issue-974-authoritative.yml +++ /dev/null @@ -1,462 +0,0 @@ -name: Authoritative verification for issue 974 - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/verify-issue-974-authoritative.yml - -permissions: - contents: write - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 120 - - steps: - - name: Check out branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Fetch upstream main - run: | - git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git - git fetch upstream main - test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" - - - name: Install Rust toolchains - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, llvm-tools-preview - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Set up Node and Python tooling - run: | - npm install --global @commitlint/cli @commitlint/config-conventional - echo "module.exports = {extends: ['@commitlint/config-conventional']}" > /tmp/commitlint.config.js - - - name: Install repository verification tools - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli - - - name: Apply final acceptance-criteria refinements - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") - file.write_text(text.replace(old, new, 1)) - - replace_once( - "crates/rmcp/src/service/client.rs", - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = serde_json::to_string(&cursor) - .expect("serializing an optional pagination cursor cannot fail"); - format!("{prefix}{cursor}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - Some(resource_read_cache_key_for_uri(¶ms.uri)) - } - - fn resource_read_cache_key_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") - }''', - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let params = serde_json::to_string(params) - .expect("serializing pagination request parameters cannot fail"); - format!("{prefix}{params}") - } - - fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - let serialized = serde_json::to_string(params) - .expect("serializing resource request parameters cannot fail"); - Some(format!( - "{}{serialized}", - resource_read_cache_prefix_for_uri(¶ms.uri) - )) - } - - fn resource_read_cache_prefix_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) - .await; - }''', - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) - .await; - }''', - ) - - replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - let ttl_policy_changed = cache.config.default_ttl != config.default_ttl - || cache.config.max_ttl != config.max_ttl; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled || ttl_policy_changed { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - }''', - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - } - - #[test] - fn result_affecting_metadata_is_part_of_cache_keys() { - let mut first_meta = crate::model::Meta::new(); - first_meta.insert("variant".into(), serde_json::json!("a")); - let mut second_meta = crate::model::Meta::new(); - second_meta.insert("variant".into(), serde_json::json!("b")); - - let first_page = Some(PaginatedRequestParams { - meta: Some(first_meta.clone()), - cursor: Some("page".into()), - }); - let second_page = Some(PaginatedRequestParams { - meta: Some(second_meta.clone()), - cursor: Some("page".into()), - }); - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) - ); - - let first_resource = - ReadResourceRequestParams::new("file:///example").with_meta(first_meta); - let second_resource = - ReadResourceRequestParams::new("file:///example").with_meta(second_meta); - assert_ne!( - resource_read_cache_key(&first_resource), - resource_read_cache_key(&second_resource) - ); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn resource_update_invalidates_only_the_matching_uri() { - let peer = disconnected_peer(); - let first_key = resource_read_cache_key_for_uri("file:///first"); - let second_key = resource_read_cache_key_for_uri("file:///second"); - for key in [&first_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ''' #[tokio::test] - async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { - let peer = disconnected_peer(); - let first_plain = ReadResourceRequestParams::new("file:///first"); - let mut meta = crate::model::Meta::new(); - meta.insert("variant".into(), serde_json::json!("a")); - let first_with_meta = - ReadResourceRequestParams::new("file:///first").with_meta(meta); - let second = ReadResourceRequestParams::new("file:///second"); - let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); - let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); - let second_key = resource_read_cache_key(&second).unwrap(); - for key in [&first_plain_key, &first_meta_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_plain_key).await.is_none()); - assert!(peer.cached_response(&first_meta_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' let first = resource_read_cache_key_for_uri("file:///first"); - let second = resource_read_cache_key_for_uri("file:///second");''', - ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) - .unwrap(); - let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) - .unwrap();''', - ) - - replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', - ''' #[tokio::test] - async fn changing_ttl_policy_invalidates_existing_entries() { - let peer = disconnected_peer(); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - assert!(peer.cached_response(&key).await.is_some()); - - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - - assert!(peer.cached_response(&key).await.is_none()); - } - - #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', - ) - - path = "crates/rmcp/tests/test_tool_disable_notification.rs" - replace_once( - path, - 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - ) - replace_once(path, ' trigger_enable: Arc,\n}', ' trigger_enable: Arc,\n list_count: Arc,\n}') - replace_once( - path, - ' trigger_enable: Arc::new(Notify::new()),\n }', - ' trigger_enable: Arc::new(Notify::new()),\n list_count: Arc::new(AtomicUsize::new(0)),\n }', - ) - replace_once( - path, - ''' let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - })''', - ''' self.list_count.fetch_add(1, Ordering::SeqCst); - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - } - .with_ttl_ms(60_000) - .with_cache_scope(CacheScope::Public))''', - ) - replace_once( - path, - ' let trigger_enable = server.trigger_enable.clone();\n', - ' let trigger_enable = server.trigger_enable.clone();\n let list_count = server.list_count.clone();\n', - ) - replace_once( - path, - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - trigger_disable.notify_one();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - let cached_tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(cached_tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - trigger_disable.notify_one();''', - ) - replace_once( - path, - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b");''', - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b"); - assert_eq!(list_count.load(Ordering::SeqCst), 2);''', - ) - replace_once( - path, - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - client_service.cancel().await.unwrap();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 3); - - client_service.cancel().await.unwrap();''', - ) - - replace_once( - "docs/CLIENT_CACHING.md", - '''Cache keys include the method and result-affecting parameters: the cursor for - paginated list methods and the URI for resource reads. MRTR retries containing - `inputResponses` or `requestState` are never cached.''', - '''Cache keys include the method and all currently result-affecting parameters: the - cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource - reads. MRTR retries containing `inputResponses` or `requestState` are never cached. - A response that omits `cacheScope` is treated as private rather than made shareable.''', - ) - PY - - - name: Run complete verification matrix - shell: bash - run: | - set +e - report=/tmp/issue-974-verification.txt - : > "$report" - failed=0 - - check() { - name="$1" - shift - echo "==> $name" - "$@" - status=$? - if [ "$status" -eq 0 ]; then - echo "$name=success" | tee -a "$report" - else - echo "$name=failed($status)" | tee -a "$report" - failed=1 - fi - } - - check nightly_format cargo +nightly fmt --all - check nightly_format_check cargo +nightly fmt --all -- --check - check whitespace git diff --check - check workspace_check cargo check --workspace --all-targets --all-features - check clippy cargo clippy --all-targets --all-features -- -D warnings - check all_feature_tests cargo test --all-features - - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - check no_local_tests cargo test -p rmcp --features "$FEATURES" - check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture - check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - check doctests cargo test -p rmcp --doc --all-features - check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - check conformance cargo test --manifest-path conformance/Cargo.toml --all-features - check semver_default cargo semver-checks --package rmcp --baseline-rev upstream/main --release-type minor --only-explicit-features --features default - check semver_non_local cargo semver-checks --package rmcp --baseline-rev upstream/main --release-type minor --only-explicit-features --features "$FEATURES" - check public_api_default cargo public-api --package rmcp -ss diff --deny changed --deny removed --force upstream/main..HEAD - check public_api_non_local cargo public-api --package rmcp --features "$FEATURES" -ss diff --deny changed --deny removed --force upstream/main..HEAD - check coverage cargo llvm-cov -p rmcp --all-features --no-report - check spelling typos - - echo "upstream_main=$(git rev-parse upstream/main)" >> "$report" - echo "fork_main=$(git rev-parse origin/main)" >> "$report" - - if [ "$failed" -ne 0 ]; then - cp "$report" /tmp/report-copy - git reset --hard HEAD - cp /tmp/report-copy .issue-974-verification-result.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .issue-974-verification-result.txt - git commit -m "ci: record issue 974 verification failure" - git push origin HEAD:feat/issue-974-client-response-cache - exit 1 - fi - - cp "$report" .issue-974-verification-result.txt - - - name: Commit verified implementation - run: | - rm .github/workflows/verify-issue-974-authoritative.yml - git config user.name "starsaintf" - git config user.email "52841317+starsaintf@users.noreply.github.com" - git add -A - git commit \ - -m "feat(client): honor SEP-2549 cache hints" \ - -m "Add configurable TTL-aware client response caching with authorization-context partitioning, complete request cache keys, pagination and notification invalidation, documentation, and regression coverage." - npx commitlint --config /tmp/commitlint.config.js --from HEAD~1 --to HEAD --verbose - git push origin HEAD:feat/issue-974-client-response-cache From 458e2bc3c3b21c79aad2d4fedeb4846268ebf28d Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:18:19 +0100 Subject: [PATCH 37/48] ci: apply final issue 974 refinements --- .github/workflows/apply-issue-974-final.yml | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/apply-issue-974-final.yml diff --git a/.github/workflows/apply-issue-974-final.yml b/.github/workflows/apply-issue-974-final.yml new file mode 100644 index 00000000..5ffbf13f --- /dev/null +++ b/.github/workflows/apply-issue-974-final.yml @@ -0,0 +1,62 @@ +name: Apply final issue 974 refinements + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/apply-issue-974-final.yml + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - run: rustup toolchain install nightly --component rustfmt + + - name: Apply deterministic patch + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/issue_974_final_patch.py \ + --output /tmp/issue_974_final_patch.py + python3 /tmp/issue_974_final_patch.py + + - name: Format and verify + run: | + cargo +nightly fmt --all + cargo +nightly fmt --all -- --check + git diff --check + + - name: Run focused and repository checks + run: | + cargo test -p rmcp --all-features service::client::tests -- --nocapture + cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + cargo clippy --all-targets --all-features -- -D warnings + cargo test --all-features + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + + - name: Commit verified refinements + run: | + rm .github/workflows/apply-issue-974-final.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): complete response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From 09db9076a431f13d1b86bcc55d6a86d27fab6c0e Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:18:56 +0100 Subject: [PATCH 38/48] ci: apply issue 974 refinements immediately --- .github/workflows/apply-issue-974-now.yml | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/apply-issue-974-now.yml diff --git a/.github/workflows/apply-issue-974-now.yml b/.github/workflows/apply-issue-974-now.yml new file mode 100644 index 00000000..7358b1cb --- /dev/null +++ b/.github/workflows/apply-issue-974-now.yml @@ -0,0 +1,38 @@ +name: Apply issue 974 refinements now + +on: + push: + branches: + - feat/issue-974-client-response-cache + paths: + - .github/workflows/apply-issue-974-now.yml + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/issue-974-client-response-cache + fetch-depth: 0 + + - name: Apply refinements + run: | + curl --fail --silent --show-error --location \ + https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ + --output /tmp/refine.py.gz.b64 + base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py + python3 /tmp/refine.py + + - name: Commit source immediately + run: | + rm .github/workflows/apply-issue-974-now.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(client): harden response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache From 31b4b16c60a775c05075ca3ee1acac0c60400afe Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:20:43 +0100 Subject: [PATCH 39/48] ci: keep issue 974 patch in rust-sdk --- ci/issue_974_final_patch.py | 334 ++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 ci/issue_974_final_patch.py diff --git a/ci/issue_974_final_patch.py b/ci/issue_974_final_patch.py new file mode 100644 index 00000000..c323779b --- /dev/null +++ b/ci/issue_974_final_patch.py @@ -0,0 +1,334 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") + file.write_text(text.replace(old, new, 1)) + + +replace_once( + "crates/rmcp/src/service/client.rs", + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = serde_json::to_string(&cursor) + .expect("serializing an optional pagination cursor cannot fail"); + format!("{prefix}{cursor}") +} + +fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + Some(resource_read_cache_key_for_uri(¶ms.uri)) +} + +fn resource_read_cache_key_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") +}''', + '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { + let params = serde_json::to_string(params) + .expect("serializing pagination request parameters cannot fail"); + format!("{prefix}{params}") +} + +fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + let serialized = serde_json::to_string(params) + .expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{serialized}", + resource_read_cache_prefix_for_uri(¶ms.uri) + )) +} + +fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") +}''', +) + +replace_once( + "crates/rmcp/src/service/client.rs", + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) + .await; + }''', + ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + }''', +) + +replace_once( + "crates/rmcp/src/service/client/cache.rs", + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', + ''' let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit();''', +) + +replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + }''', + ''' #[test] + fn paginated_pages_have_independent_cache_keys() { + let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); + let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); + + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) + ); + } + + #[test] + fn result_affecting_metadata_is_part_of_cache_keys() { + let mut first_meta = crate::model::Meta::new(); + first_meta.insert("variant".into(), serde_json::json!("a")); + let mut second_meta = crate::model::Meta::new(); + second_meta.insert("variant".into(), serde_json::json!("b")); + + let first_page = Some(PaginatedRequestParams { + meta: Some(first_meta.clone()), + cursor: Some("page".into()), + }); + let second_page = Some(PaginatedRequestParams { + meta: Some(second_meta.clone()), + cursor: Some("page".into()), + }); + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) + ); + + let first_resource = + ReadResourceRequestParams::new("file:///example").with_meta(first_meta); + let second_resource = + ReadResourceRequestParams::new("file:///example").with_meta(second_meta); + assert_ne!( + resource_read_cache_key(&first_resource), + resource_read_cache_key(&second_resource) + ); + }''', +) + +replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn resource_update_invalidates_only_the_matching_uri() { + let peer = disconnected_peer(); + let first_key = resource_read_cache_key_for_uri("file:///first"); + let second_key = resource_read_cache_key_for_uri("file:///second"); + for key in [&first_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', + ''' #[tokio::test] + async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { + let peer = disconnected_peer(); + let first_plain = ReadResourceRequestParams::new("file:///first"); + let mut meta = crate::model::Meta::new(); + meta.insert("variant".into(), serde_json::json!("a")); + let first_with_meta = ReadResourceRequestParams::new("file:///first").with_meta(meta); + let second = ReadResourceRequestParams::new("file:///second"); + let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); + let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); + let second_key = resource_read_cache_key(&second).unwrap(); + for key in [&first_plain_key, &first_meta_key, &second_key] { + peer.cache_response( + key.clone(), + ServerResult::ReadResourceResult( + ReadResourceResult::new(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private), + ), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + } + + peer.invalidate_resource_read_cache("file:///first").await; + + assert!(peer.cached_response(&first_plain_key).await.is_none()); + assert!(peer.cached_response(&first_meta_key).await.is_none()); + assert!(peer.cached_response(&second_key).await.is_some()); + }''', +) + +replace_once( + "crates/rmcp/src/service/client.rs", + ''' let first = resource_read_cache_key_for_uri("file:///first"); + let second = resource_read_cache_key_for_uri("file:///second");''', + ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) + .unwrap(); + let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) + .unwrap();''', +) + +replace_once( + "crates/rmcp/src/service/client.rs", + ''' #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', + ''' #[tokio::test] + async fn changing_ttl_policy_invalidates_existing_entries() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_client_peers() {''', +) + +path = "crates/rmcp/tests/test_tool_disable_notification.rs" +replace_once( + path, + 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', + 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', +) +replace_once(path, ' trigger_enable: Arc,\n}', ' trigger_enable: Arc,\n list_count: Arc,\n}') +replace_once( + path, + ' trigger_enable: Arc::new(Notify::new()),\n }', + ' trigger_enable: Arc::new(Notify::new()),\n list_count: Arc::new(AtomicUsize::new(0)),\n }', +) +replace_once( + path, + ''' let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + })''', + ''' self.list_count.fetch_add(1, Ordering::SeqCst); + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + } + .with_ttl_ms(60_000) + .with_cache_scope(CacheScope::Public))''', +) +replace_once( + path, + ' let trigger_enable = server.trigger_enable.clone();\n', + ' let trigger_enable = server.trigger_enable.clone();\n let list_count = server.list_count.clone();\n', +) +replace_once( + path, + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + let cached_tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(cached_tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + trigger_disable.notify_one();''', +) +replace_once( + path, + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b");''', + ''' assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + assert_eq!(list_count.load(Ordering::SeqCst), 2);''', +) +replace_once( + path, + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap();''', + ''' let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 3); + + client_service.cancel().await.unwrap();''', +) + +replace_once( + "docs/CLIENT_CACHING.md", + '''Cache keys include the method and result-affecting parameters: the cursor for +paginated list methods and the URI for resource reads. MRTR retries containing +`inputResponses` or `requestState` are never cached.''', + '''Cache keys include the method and all currently result-affecting parameters: the +cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource +reads. MRTR retries containing `inputResponses` or `requestState` are never cached. +A response that omits `cacheScope` is treated as private rather than made shareable.''', +) From 7b419fd48bf2d38aa4a5e45fe6b2c06def6d8ec9 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:21:02 +0100 Subject: [PATCH 40/48] ci: run issue 974 validation inside rust-sdk --- .github/workflows/apply-issue-974-final.yml | 42 ++++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/apply-issue-974-final.yml b/.github/workflows/apply-issue-974-final.yml index 5ffbf13f..b8ebc9a8 100644 --- a/.github/workflows/apply-issue-974-final.yml +++ b/.github/workflows/apply-issue-974-final.yml @@ -1,4 +1,4 @@ -name: Apply final issue 974 refinements +name: Verify and apply Rust SDK issue 974 refinements on: push: @@ -11,35 +11,34 @@ permissions: contents: write jobs: - apply: + verify-and-apply: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - name: Check out implementation branch + uses: actions/checkout@v4 with: ref: feat/issue-974-client-response-cache fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable + - name: Install Rust toolchains + uses: dtolnay/rust-toolchain@stable with: components: clippy - - run: rustup toolchain install nightly --component rustfmt + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt - - name: Apply deterministic patch - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/issue_974_final_patch.py \ - --output /tmp/issue_974_final_patch.py - python3 /tmp/issue_974_final_patch.py + - name: Apply repository-local deterministic patch + run: python3 ci/issue_974_final_patch.py - - name: Format and verify + - name: Format and verify whitespace run: | cargo +nightly fmt --all cargo +nightly fmt --all -- --check git diff --check - - name: Run focused and repository checks + - name: Run issue-specific and repository checks run: | cargo test -p rmcp --all-features service::client::tests -- --nocapture cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture @@ -52,11 +51,18 @@ jobs: | select(. != "local")] | join(",")') cargo test -p rmcp --features "$FEATURES" - - name: Commit verified refinements + - name: Commit verified Rust SDK changes run: | - rm .github/workflows/apply-issue-974-final.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): complete response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache + git add \ + crates/rmcp/src/service/client.rs \ + crates/rmcp/src/service/client/cache.rs \ + crates/rmcp/tests/test_tool_disable_notification.rs \ + docs/CLIENT_CACHING.md + if git diff --cached --quiet; then + echo "No code changes to commit" + else + git commit -m "fix(client): complete response cache semantics" + git push origin HEAD:feat/issue-974-client-response-cache + fi From 844caba3139f3dafdda275211034f3352a3b03ca Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:22:08 +0100 Subject: [PATCH 41/48] ci: remove stale immediate issue 974 workflow --- .github/workflows/apply-issue-974-now.yml | 38 ----------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/apply-issue-974-now.yml diff --git a/.github/workflows/apply-issue-974-now.yml b/.github/workflows/apply-issue-974-now.yml deleted file mode 100644 index 7358b1cb..00000000 --- a/.github/workflows/apply-issue-974-now.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Apply issue 974 refinements now - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/apply-issue-974-now.yml - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Apply refinements - run: | - curl --fail --silent --show-error --location \ - https://raw.githubusercontent.com/starsaintf/codex-skills-pack/ci/rust-sdk-issue-974-validation/ci/refine_issue_974.py.gz.b64 \ - --output /tmp/refine.py.gz.b64 - base64 --decode /tmp/refine.py.gz.b64 | gzip --decompress > /tmp/refine.py - python3 /tmp/refine.py - - - name: Commit source immediately - run: | - rm .github/workflows/apply-issue-974-now.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(client): harden response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache From 20fbe3f586ad4d381ab7a66450efc133b4d3d152 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:26:10 +0100 Subject: [PATCH 42/48] chore: remove issue 974 validation workflow --- .github/workflows/apply-issue-974-final.yml | 68 --------------------- 1 file changed, 68 deletions(-) delete mode 100644 .github/workflows/apply-issue-974-final.yml diff --git a/.github/workflows/apply-issue-974-final.yml b/.github/workflows/apply-issue-974-final.yml deleted file mode 100644 index b8ebc9a8..00000000 --- a/.github/workflows/apply-issue-974-final.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Verify and apply Rust SDK issue 974 refinements - -on: - push: - branches: - - feat/issue-974-client-response-cache - paths: - - .github/workflows/apply-issue-974-final.yml - -permissions: - contents: write - -jobs: - verify-and-apply: - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - name: Check out implementation branch - uses: actions/checkout@v4 - with: - ref: feat/issue-974-client-response-cache - fetch-depth: 0 - - - name: Install Rust toolchains - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - name: Apply repository-local deterministic patch - run: python3 ci/issue_974_final_patch.py - - - name: Format and verify whitespace - run: | - cargo +nightly fmt --all - cargo +nightly fmt --all -- --check - git diff --check - - - name: Run issue-specific and repository checks - run: | - cargo test -p rmcp --all-features service::client::tests -- --nocapture - cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - cargo clippy --all-targets --all-features -- -D warnings - cargo test --all-features - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ - | select(startswith("__") | not) \ - | select(. != "local")] | join(",")') - cargo test -p rmcp --features "$FEATURES" - - - name: Commit verified Rust SDK changes - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - crates/rmcp/src/service/client.rs \ - crates/rmcp/src/service/client/cache.rs \ - crates/rmcp/tests/test_tool_disable_notification.rs \ - docs/CLIENT_CACHING.md - if git diff --cached --quiet; then - echo "No code changes to commit" - else - git commit -m "fix(client): complete response cache semantics" - git push origin HEAD:feat/issue-974-client-response-cache - fi From 936e7ecb7cb0c3a8a8b6ec3dad4eb8d3335ff23e Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:26:36 +0100 Subject: [PATCH 43/48] chore: remove issue 974 patch helper --- ci/issue_974_final_patch.py | 334 ------------------------------------ 1 file changed, 334 deletions(-) delete mode 100644 ci/issue_974_final_patch.py diff --git a/ci/issue_974_final_patch.py b/ci/issue_974_final_patch.py deleted file mode 100644 index c323779b..00000000 --- a/ci/issue_974_final_patch.py +++ /dev/null @@ -1,334 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}") - file.write_text(text.replace(old, new, 1)) - - -replace_once( - "crates/rmcp/src/service/client.rs", - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = serde_json::to_string(&cursor) - .expect("serializing an optional pagination cursor cannot fail"); - format!("{prefix}{cursor}") -} - -fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - Some(resource_read_cache_key_for_uri(¶ms.uri)) -} - -fn resource_read_cache_key_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") -}''', - '''fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let params = serde_json::to_string(params) - .expect("serializing pagination request parameters cannot fail"); - format!("{prefix}{params}") -} - -fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - let serialized = serde_json::to_string(params) - .expect("serializing resource request parameters cannot fail"); - Some(format!( - "{}{serialized}", - resource_read_cache_prefix_for_uri(¶ms.uri) - )) -} - -fn resource_read_cache_prefix_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") -}''', -) - -replace_once( - "crates/rmcp/src/service/client.rs", - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) - .await; - }''', - ''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) - .await; - }''', -) - -replace_once( - "crates/rmcp/src/service/client/cache.rs", - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', - ''' let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - let ttl_policy_changed = cache.config.default_ttl != config.default_ttl - || cache.config.max_ttl != config.max_ttl; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled || ttl_policy_changed { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit();''', -) - -replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - }''', - ''' #[test] - fn paginated_pages_have_independent_cache_keys() { - let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into()))); - let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into()))); - - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second) - ); - } - - #[test] - fn result_affecting_metadata_is_part_of_cache_keys() { - let mut first_meta = crate::model::Meta::new(); - first_meta.insert("variant".into(), serde_json::json!("a")); - let mut second_meta = crate::model::Meta::new(); - second_meta.insert("variant".into(), serde_json::json!("b")); - - let first_page = Some(PaginatedRequestParams { - meta: Some(first_meta.clone()), - cursor: Some("page".into()), - }); - let second_page = Some(PaginatedRequestParams { - meta: Some(second_meta.clone()), - cursor: Some("page".into()), - }); - assert_ne!( - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), - list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) - ); - - let first_resource = - ReadResourceRequestParams::new("file:///example").with_meta(first_meta); - let second_resource = - ReadResourceRequestParams::new("file:///example").with_meta(second_meta); - assert_ne!( - resource_read_cache_key(&first_resource), - resource_read_cache_key(&second_resource) - ); - }''', -) - -replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn resource_update_invalidates_only_the_matching_uri() { - let peer = disconnected_peer(); - let first_key = resource_read_cache_key_for_uri("file:///first"); - let second_key = resource_read_cache_key_for_uri("file:///second"); - for key in [&first_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', - ''' #[tokio::test] - async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { - let peer = disconnected_peer(); - let first_plain = ReadResourceRequestParams::new("file:///first"); - let mut meta = crate::model::Meta::new(); - meta.insert("variant".into(), serde_json::json!("a")); - let first_with_meta = ReadResourceRequestParams::new("file:///first").with_meta(meta); - let second = ReadResourceRequestParams::new("file:///second"); - let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); - let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); - let second_key = resource_read_cache_key(&second).unwrap(); - for key in [&first_plain_key, &first_meta_key, &second_key] { - peer.cache_response( - key.clone(), - ServerResult::ReadResourceResult( - ReadResourceResult::new(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private), - ), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - } - - peer.invalidate_resource_read_cache("file:///first").await; - - assert!(peer.cached_response(&first_plain_key).await.is_none()); - assert!(peer.cached_response(&first_meta_key).await.is_none()); - assert!(peer.cached_response(&second_key).await.is_some()); - }''', -) - -replace_once( - "crates/rmcp/src/service/client.rs", - ''' let first = resource_read_cache_key_for_uri("file:///first"); - let second = resource_read_cache_key_for_uri("file:///second");''', - ''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")) - .unwrap(); - let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")) - .unwrap();''', -) - -replace_once( - "crates/rmcp/src/service/client.rs", - ''' #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', - ''' #[tokio::test] - async fn changing_ttl_policy_invalidates_existing_entries() { - let peer = disconnected_peer(); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), - Some(60_000), - Some(CacheScope::Public), - ) - .await; - assert!(peer.cached_response(&key).await.is_some()); - - peer.set_response_cache_config( - ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), - ) - .await; - - assert!(peer.cached_response(&key).await.is_none()); - } - - #[tokio::test] - async fn private_entries_are_isolated_between_client_peers() {''', -) - -path = "crates/rmcp/tests/test_tool_disable_notification.rs" -replace_once( - path, - 'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', - 'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},', -) -replace_once(path, ' trigger_enable: Arc,\n}', ' trigger_enable: Arc,\n list_count: Arc,\n}') -replace_once( - path, - ' trigger_enable: Arc::new(Notify::new()),\n }', - ' trigger_enable: Arc::new(Notify::new()),\n list_count: Arc::new(AtomicUsize::new(0)),\n }', -) -replace_once( - path, - ''' let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - })''', - ''' self.list_count.fetch_add(1, Ordering::SeqCst); - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - } - .with_ttl_ms(60_000) - .with_cache_scope(CacheScope::Public))''', -) -replace_once( - path, - ' let trigger_enable = server.trigger_enable.clone();\n', - ' let trigger_enable = server.trigger_enable.clone();\n let list_count = server.list_count.clone();\n', -) -replace_once( - path, - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - trigger_disable.notify_one();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - let cached_tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(cached_tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 1); - - trigger_disable.notify_one();''', -) -replace_once( - path, - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b");''', - ''' assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b"); - assert_eq!(list_count.load(Ordering::SeqCst), 2);''', -) -replace_once( - path, - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - client_service.cancel().await.unwrap();''', - ''' let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - assert_eq!(list_count.load(Ordering::SeqCst), 3); - - client_service.cancel().await.unwrap();''', -) - -replace_once( - "docs/CLIENT_CACHING.md", - '''Cache keys include the method and result-affecting parameters: the cursor for -paginated list methods and the URI for resource reads. MRTR retries containing -`inputResponses` or `requestState` are never cached.''', - '''Cache keys include the method and all currently result-affecting parameters: the -cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource -reads. MRTR retries containing `inputResponses` or `requestState` are never cached. -A response that omits `cacheScope` is treated as private rather than made shareable.''', -) From cec152a02f90bbeebed4355eb9e98394e22ec231 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:52:56 +0100 Subject: [PATCH 44/48] style: apply nightly rustfmt ordering --- crates/rmcp/src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index e70795dc..46d0f847 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -16,12 +16,10 @@ pub use handler::client::ClientHandler; pub use handler::server::ServerHandler; #[cfg(feature = "server")] pub use handler::server::wrapper::Json; +#[cfg(feature = "client")] +pub use service::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL, RoleClient, serve_client}; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; -#[cfg(feature = "client")] -pub use service::{ - ClientCacheConfig, MAX_CLIENT_CACHE_TTL, RoleClient, serve_client, -}; #[cfg(feature = "server")] pub use service::{RoleServer, serve_server}; From b86a5ba24b20a6ff955413e4b2427b0b66c6963d Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:01:10 +0100 Subject: [PATCH 45/48] ci: verify issue 974 on isolated branch --- .../workflows/verify-issue-974-isolated.yml | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .github/workflows/verify-issue-974-isolated.yml diff --git a/.github/workflows/verify-issue-974-isolated.yml b/.github/workflows/verify-issue-974-isolated.yml new file mode 100644 index 00000000..303a52be --- /dev/null +++ b/.github/workflows/verify-issue-974-isolated.yml @@ -0,0 +1,127 @@ +name: Verify issue 974 on isolated branch + +on: + push: + branches: + - tmp/issue-974-clean-final + paths: + - .github/workflows/verify-issue-974-isolated.yml + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v7 + with: + ref: tmp/issue-974-clean-final + fetch-depth: 0 + + - name: Apply hardening source commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git cherry-pick 4c5975d19a4a1798051f1dd0def19bb8eff9403e + + - uses: actions/setup-node@v6 + with: + node-version: '22' + + - uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: | + uv python install + uv venv + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, llvm-tools-preview + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli + + - name: Fetch upstream + run: | + git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git + git fetch upstream main + + - name: Run corrected verification matrix + id: matrix + shell: bash + run: | + set +e + report=.issue-974-isolated-result.txt + : > "$report" + failed=0 + check() { + name="$1"; shift + "$@" + code=$? + if [ "$code" -eq 0 ]; then + echo "$name=success" | tee -a "$report" + else + echo "$name=failed($code)" | tee -a "$report" + failed=1 + fi + } + + check upstream_alignment test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" + check nightly_format cargo +nightly fmt --all -- --check + check whitespace git diff --check upstream/main...HEAD + check clippy cargo clippy --all-targets --all-features -- -D warnings + check all_feature_tests cargo test --all-features + + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")] | join(",")') + check no_local_tests cargo test -p rmcp --features "$FEATURES" + + check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + check doctests cargo test -p rmcp --doc --all-features + check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + check conformance cargo test --manifest-path conformance/Cargo.toml --all-features + check semver_default cargo semver-checks \ + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features default + check semver_non_local cargo semver-checks \ + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features "$FEATURES" + check public_api_default cargo public-api --package rmcp -ss diff \ + --deny changed --deny removed --force upstream/main..HEAD + check public_api_non_local cargo public-api --package rmcp \ + --features "$FEATURES" -ss diff --deny changed --deny removed \ + --force upstream/main..HEAD + check coverage cargo llvm-cov -p rmcp --all-features --no-report + check spelling typos + + echo "tested_sha=$(git rev-parse HEAD)" >> "$report" + echo "upstream_main=$(git rev-parse upstream/main)" >> "$report" + if [ "$failed" -eq 0 ]; then + echo "status=success" >> "$report" + else + echo "status=failed" >> "$report" + fi + echo "failed=$failed" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Publish isolated result + run: | + rm .github/workflows/verify-issue-974-isolated.yml + git add -A + git commit -m "ci: record isolated issue 974 verification" + git push origin HEAD:tmp/issue-974-clean-final + + - name: Fail when a check failed + if: steps.matrix.outputs.failed != '0' + run: exit 1 From 024b4e5724a5efc05b7b0774adca0e46d798fe60 Mon Sep 17 00:00:00 2001 From: Jay-F <52841317+starsaintf@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:08:45 +0100 Subject: [PATCH 46/48] ci: run fail-safe isolated issue 974 verification --- .../verify-issue-974-isolated-v2.yml | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/verify-issue-974-isolated-v2.yml diff --git a/.github/workflows/verify-issue-974-isolated-v2.yml b/.github/workflows/verify-issue-974-isolated-v2.yml new file mode 100644 index 00000000..58d850a1 --- /dev/null +++ b/.github/workflows/verify-issue-974-isolated-v2.yml @@ -0,0 +1,129 @@ +name: Verify issue 974 on isolated branch v2 + +on: + push: + branches: + - tmp/issue-974-clean-final + paths: + - .github/workflows/verify-issue-974-isolated-v2.yml + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v7 + with: + ref: tmp/issue-974-clean-final + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + + - uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: | + uv python install + uv venv + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, llvm-tools-preview + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --component rustfmt + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli + + - name: Apply source and run verification + id: matrix + shell: bash + run: | + set +e + report=.issue-974-isolated-result.txt + : > "$report" + failed=0 + check() { + name="$1"; shift + "$@" + code=$? + if [ "$code" -eq 0 ]; then + echo "$name=success" | tee -a "$report" + else + echo "$name=failed($code)" | tee -a "$report" + failed=1 + fi + } + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + check fetch_source git fetch origin 4c5975d19a4a1798051f1dd0def19bb8eff9403e + if [ "$failed" -eq 0 ]; then + check cherry_pick git cherry-pick 4c5975d19a4a1798051f1dd0def19bb8eff9403e + fi + + check fetch_upstream git fetch https://github.com/modelcontextprotocol/rust-sdk.git main:refs/remotes/upstream/main + + if [ "$failed" -eq 0 ]; then + check upstream_alignment test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" + check nightly_format cargo +nightly fmt --all -- --check + check whitespace git diff --check upstream/main...HEAD + check clippy cargo clippy --all-targets --all-features -- -D warnings + check all_feature_tests cargo test --all-features + + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")] | join(",")') + check no_local_tests cargo test -p rmcp --features "$FEATURES" + + check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture + check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture + check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture + check doctests cargo test -p rmcp --doc --all-features + check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps + check conformance cargo test --manifest-path conformance/Cargo.toml --all-features + check semver_default cargo semver-checks \ + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features default + check semver_non_local cargo semver-checks \ + --package rmcp --baseline-rev upstream/main --release-type minor \ + --only-explicit-features --features "$FEATURES" + check public_api_default cargo public-api --package rmcp -ss diff \ + --deny changed --deny removed --force upstream/main..HEAD + check public_api_non_local cargo public-api --package rmcp \ + --features "$FEATURES" -ss diff --deny changed --deny removed \ + --force upstream/main..HEAD + check coverage cargo llvm-cov -p rmcp --all-features --no-report + check spelling typos + fi + + echo "tested_sha=$(git rev-parse HEAD)" >> "$report" + echo "upstream_main=$(git rev-parse upstream/main 2>/dev/null || echo unavailable)" >> "$report" + if [ "$failed" -eq 0 ]; then + echo "status=success" >> "$report" + else + echo "status=failed" >> "$report" + fi + echo "failed=$failed" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Publish isolated result + if: always() + run: | + rm -f .github/workflows/verify-issue-974-isolated.yml + rm -f .github/workflows/verify-issue-974-isolated-v2.yml + git add -A + git commit -m "ci: record isolated issue 974 verification" || true + git push origin HEAD:tmp/issue-974-clean-final + + - name: Fail when a check failed + if: steps.matrix.outputs.failed != '0' + run: exit 1 From 3506966da5a9a8fca5d1023b324a15746cfc415b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:58:58 +0000 Subject: [PATCH 47/48] fix(client): complete response cache semantics --- crates/rmcp/src/service/client.rs | 94 ++++++++++++++++--- crates/rmcp/src/service/client/cache.rs | 4 +- .../tests/test_tool_disable_notification.rs | 17 +++- docs/CLIENT_CACHING.md | 7 +- 4 files changed, 101 insertions(+), 21 deletions(-) diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index fa906816..c5b4f1de 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -285,22 +285,26 @@ const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; fn list_response_cache_key(prefix: &str, params: &Option) -> String { - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = serde_json::to_string(&cursor) - .expect("serializing an optional pagination cursor cannot fail"); - format!("{prefix}{cursor}") + let params = serde_json::to_string(params) + .expect("serializing pagination request parameters cannot fail"); + format!("{prefix}{params}") } fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { if params.input_responses.is_some() || params.request_state.is_some() { return None; } - Some(resource_read_cache_key_for_uri(¶ms.uri)) + let serialized = + serde_json::to_string(params).expect("serializing resource request parameters cannot fail"); + Some(format!( + "{}{serialized}", + resource_read_cache_prefix_for_uri(¶ms.uri) + )) } -fn resource_read_cache_key_for_uri(uri: &str) -> String { +fn resource_read_cache_prefix_for_uri(uri: &str) -> String { let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}") + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") } fn request_uses_cursor(params: &Option) -> bool { @@ -433,7 +437,7 @@ impl Peer { } pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri)) + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) .await; } @@ -1292,6 +1296,27 @@ mod tests { )); } + #[tokio::test] + async fn changing_ttl_policy_invalidates_existing_entries() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))), + Some(60_000), + Some(CacheScope::Public), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)), + ) + .await; + + assert!(peer.cached_response(&key).await.is_none()); + } + #[tokio::test] async fn private_entries_are_isolated_between_client_peers() { let first = disconnected_peer(); @@ -1381,6 +1406,36 @@ mod tests { ); } + #[test] + fn result_affecting_metadata_is_part_of_cache_keys() { + let mut first_meta = crate::model::Meta::new(); + first_meta.insert("variant".into(), serde_json::json!("a")); + let mut second_meta = crate::model::Meta::new(); + second_meta.insert("variant".into(), serde_json::json!("b")); + + let first_page = Some(PaginatedRequestParams { + meta: Some(first_meta.clone()), + cursor: Some("page".into()), + }); + let second_page = Some(PaginatedRequestParams { + meta: Some(second_meta.clone()), + cursor: Some("page".into()), + }); + assert_ne!( + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page), + list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page) + ); + + let first_resource = + ReadResourceRequestParams::new("file:///example").with_meta(first_meta); + let second_resource = + ReadResourceRequestParams::new("file:///example").with_meta(second_meta); + assert_ne!( + resource_read_cache_key(&first_resource), + resource_read_cache_key(&second_resource) + ); + } + #[tokio::test] async fn list_invalidation_discards_every_cached_page() { let peer = disconnected_peer(); @@ -1408,11 +1463,17 @@ mod tests { } #[tokio::test] - async fn resource_update_invalidates_only_the_matching_uri() { + async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() { let peer = disconnected_peer(); - let first_key = resource_read_cache_key_for_uri("file:///first"); - let second_key = resource_read_cache_key_for_uri("file:///second"); - for key in [&first_key, &second_key] { + let first_plain = ReadResourceRequestParams::new("file:///first"); + let mut meta = crate::model::Meta::new(); + meta.insert("variant".into(), serde_json::json!("a")); + let first_with_meta = ReadResourceRequestParams::new("file:///first").with_meta(meta); + let second = ReadResourceRequestParams::new("file:///second"); + let first_plain_key = resource_read_cache_key(&first_plain).unwrap(); + let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap(); + let second_key = resource_read_cache_key(&second).unwrap(); + for key in [&first_plain_key, &first_meta_key, &second_key] { peer.cache_response( key.clone(), ServerResult::ReadResourceResult( @@ -1428,7 +1489,8 @@ mod tests { peer.invalidate_resource_read_cache("file:///first").await; - assert!(peer.cached_response(&first_key).await.is_none()); + assert!(peer.cached_response(&first_plain_key).await.is_none()); + assert!(peer.cached_response(&first_meta_key).await.is_none()); assert!(peer.cached_response(&second_key).await.is_some()); } @@ -1485,8 +1547,10 @@ mod tests { let peer = disconnected_peer(); peer.set_response_cache_config(ClientCacheConfig::default().with_max_entries(1)) .await; - let first = resource_read_cache_key_for_uri("file:///first"); - let second = resource_read_cache_key_for_uri("file:///second"); + let first = + resource_read_cache_key(&ReadResourceRequestParams::new("file:///first")).unwrap(); + let second = + resource_read_cache_key(&ReadResourceRequestParams::new("file:///second")).unwrap(); peer.cache_response( first.clone(), ServerResult::ReadResourceResult(ReadResourceResult::new(Vec::new())), diff --git a/crates/rmcp/src/service/client/cache.rs b/crates/rmcp/src/service/client/cache.rs index e9bb6cda..92dfdb92 100644 --- a/crates/rmcp/src/service/client/cache.rs +++ b/crates/rmcp/src/service/client/cache.rs @@ -319,11 +319,13 @@ impl Peer { let mut cache = self.response_cache.write().await; let config_changed = cache.config != config; let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; cache.config = config; if config_changed { cache.generation = cache.generation.wrapping_add(1); } - if !cache.config.enabled { + if !cache.config.enabled || ttl_policy_changed { cache.entries.clear(); } else if partition_changed { cache diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs index cd878059..497878bd 100644 --- a/crates/rmcp/tests/test_tool_disable_notification.rs +++ b/crates/rmcp/tests/test_tool_disable_notification.rs @@ -9,7 +9,7 @@ use std::sync::{ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRoute, tool::ToolCallContext}, - model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, + model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, service::{MaybeSendFuture, NotificationContext}, }; use tokio::sync::{Notify, RwLock}; @@ -19,6 +19,7 @@ struct TestToolServer { router: Arc>>, trigger_disable: Arc, trigger_enable: Arc, + list_count: Arc, } impl TestToolServer { @@ -36,6 +37,7 @@ impl TestToolServer { router: Arc::new(RwLock::new(tool_router)), trigger_disable: Arc::new(Notify::new()), trigger_enable: Arc::new(Notify::new()), + list_count: Arc::new(AtomicUsize::new(0)), } } } @@ -60,11 +62,14 @@ impl ServerHandler for TestToolServer { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { + self.list_count.fetch_add(1, Ordering::SeqCst); let router = self.router.read().await; Ok(rmcp::model::ListToolsResult { tools: router.list_all(), ..Default::default() - }) + } + .with_ttl_ms(60_000) + .with_cache_scope(CacheScope::Public)) } fn on_initialized( @@ -128,6 +133,7 @@ async fn test_disable_enable_sends_tool_list_changed() { let server = TestToolServer::new(); let trigger_disable = server.trigger_disable.clone(); let trigger_enable = server.trigger_enable.clone(); + let list_count = server.list_count.clone(); let client = TestToolClient::new(); let notification_count = client.notification_count.clone(); @@ -140,6 +146,11 @@ async fn test_disable_enable_sends_tool_list_changed() { let tools = client_service.peer().list_tools(None).await.unwrap(); assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); + + let cached_tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(cached_tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 1); trigger_disable.notify_one(); tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) @@ -150,6 +161,7 @@ async fn test_disable_enable_sends_tool_list_changed() { let tools = client_service.peer().list_tools(None).await.unwrap(); assert_eq!(tools.tools.len(), 1); assert_eq!(tools.tools[0].name, "tool_b"); + assert_eq!(list_count.load(Ordering::SeqCst), 2); trigger_enable.notify_one(); tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) @@ -159,6 +171,7 @@ async fn test_disable_enable_sends_tool_list_changed() { let tools = client_service.peer().list_tools(None).await.unwrap(); assert_eq!(tools.tools.len(), 2); + assert_eq!(list_count.load(Ordering::SeqCst), 3); client_service.cancel().await.unwrap(); server_handle.abort(); diff --git a/docs/CLIENT_CACHING.md b/docs/CLIENT_CACHING.md index 80f405e0..ca12d80d 100644 --- a/docs/CLIENT_CACHING.md +++ b/docs/CLIENT_CACHING.md @@ -34,9 +34,10 @@ Use `ClientCacheConfig::disabled()` to disable cache reads and writes, or `with_max_entries()` bounds the in-memory store; the default is 512 entries and a value of zero removes the limit. -Cache keys include the method and result-affecting parameters: the cursor for -paginated list methods and the URI for resource reads. MRTR retries containing -`inputResponses` or `requestState` are never cached. List-change notifications +Cache keys include the method and all currently result-affecting parameters: the +cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource +reads. MRTR retries containing `inputResponses` or `requestState` are never cached. +A response that omits `cacheScope` is treated as private rather than made shareable. List-change notifications invalidate every cached page for the corresponding method, while resource update notifications invalidate only the matching URI. When a cursor request fails, all cached pages for that list method are discarded so the next walk can From d01b2a0028f58c4680bab6357fa1ffed903110a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:21:10 +0000 Subject: [PATCH 48/48] ci: record isolated issue 974 verification --- .../verify-issue-974-isolated-v2.yml | 129 ------------------ .../workflows/verify-issue-974-isolated.yml | 127 ----------------- .issue-974-isolated-result.txt | 24 ++++ 3 files changed, 24 insertions(+), 256 deletions(-) delete mode 100644 .github/workflows/verify-issue-974-isolated-v2.yml delete mode 100644 .github/workflows/verify-issue-974-isolated.yml create mode 100644 .issue-974-isolated-result.txt diff --git a/.github/workflows/verify-issue-974-isolated-v2.yml b/.github/workflows/verify-issue-974-isolated-v2.yml deleted file mode 100644 index 58d850a1..00000000 --- a/.github/workflows/verify-issue-974-isolated-v2.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Verify issue 974 on isolated branch v2 - -on: - push: - branches: - - tmp/issue-974-clean-final - paths: - - .github/workflows/verify-issue-974-isolated-v2.yml - -permissions: - contents: write - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 180 - steps: - - uses: actions/checkout@v7 - with: - ref: tmp/issue-974-clean-final - fetch-depth: 0 - - - uses: actions/setup-node@v6 - with: - node-version: '22' - - - uses: astral-sh/setup-uv@v7 - - - name: Set up Python - run: | - uv python install - uv venv - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, llvm-tools-preview - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli - - - name: Apply source and run verification - id: matrix - shell: bash - run: | - set +e - report=.issue-974-isolated-result.txt - : > "$report" - failed=0 - check() { - name="$1"; shift - "$@" - code=$? - if [ "$code" -eq 0 ]; then - echo "$name=success" | tee -a "$report" - else - echo "$name=failed($code)" | tee -a "$report" - failed=1 - fi - } - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - check fetch_source git fetch origin 4c5975d19a4a1798051f1dd0def19bb8eff9403e - if [ "$failed" -eq 0 ]; then - check cherry_pick git cherry-pick 4c5975d19a4a1798051f1dd0def19bb8eff9403e - fi - - check fetch_upstream git fetch https://github.com/modelcontextprotocol/rust-sdk.git main:refs/remotes/upstream/main - - if [ "$failed" -eq 0 ]; then - check upstream_alignment test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" - check nightly_format cargo +nightly fmt --all -- --check - check whitespace git diff --check upstream/main...HEAD - check clippy cargo clippy --all-targets --all-features -- -D warnings - check all_feature_tests cargo test --all-features - - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] - | select(startswith("__") | not) - | select(. != "local")] | join(",")') - check no_local_tests cargo test -p rmcp --features "$FEATURES" - - check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture - check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - check doctests cargo test -p rmcp --doc --all-features - check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - check conformance cargo test --manifest-path conformance/Cargo.toml --all-features - check semver_default cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features default - check semver_non_local cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features "$FEATURES" - check public_api_default cargo public-api --package rmcp -ss diff \ - --deny changed --deny removed --force upstream/main..HEAD - check public_api_non_local cargo public-api --package rmcp \ - --features "$FEATURES" -ss diff --deny changed --deny removed \ - --force upstream/main..HEAD - check coverage cargo llvm-cov -p rmcp --all-features --no-report - check spelling typos - fi - - echo "tested_sha=$(git rev-parse HEAD)" >> "$report" - echo "upstream_main=$(git rev-parse upstream/main 2>/dev/null || echo unavailable)" >> "$report" - if [ "$failed" -eq 0 ]; then - echo "status=success" >> "$report" - else - echo "status=failed" >> "$report" - fi - echo "failed=$failed" >> "$GITHUB_OUTPUT" - exit 0 - - - name: Publish isolated result - if: always() - run: | - rm -f .github/workflows/verify-issue-974-isolated.yml - rm -f .github/workflows/verify-issue-974-isolated-v2.yml - git add -A - git commit -m "ci: record isolated issue 974 verification" || true - git push origin HEAD:tmp/issue-974-clean-final - - - name: Fail when a check failed - if: steps.matrix.outputs.failed != '0' - run: exit 1 diff --git a/.github/workflows/verify-issue-974-isolated.yml b/.github/workflows/verify-issue-974-isolated.yml deleted file mode 100644 index 303a52be..00000000 --- a/.github/workflows/verify-issue-974-isolated.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: Verify issue 974 on isolated branch - -on: - push: - branches: - - tmp/issue-974-clean-final - paths: - - .github/workflows/verify-issue-974-isolated.yml - -permissions: - contents: write - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 180 - steps: - - uses: actions/checkout@v7 - with: - ref: tmp/issue-974-clean-final - fetch-depth: 0 - - - name: Apply hardening source commit - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git cherry-pick 4c5975d19a4a1798051f1dd0def19bb8eff9403e - - - uses: actions/setup-node@v6 - with: - node-version: '22' - - - uses: astral-sh/setup-uv@v7 - - - name: Set up Python - run: | - uv python install - uv venv - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, llvm-tools-preview - - - name: Install nightly rustfmt - run: rustup toolchain install nightly --component rustfmt - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-semver-checks,cargo-public-api,cargo-llvm-cov,typos-cli - - - name: Fetch upstream - run: | - git remote add upstream https://github.com/modelcontextprotocol/rust-sdk.git - git fetch upstream main - - - name: Run corrected verification matrix - id: matrix - shell: bash - run: | - set +e - report=.issue-974-isolated-result.txt - : > "$report" - failed=0 - check() { - name="$1"; shift - "$@" - code=$? - if [ "$code" -eq 0 ]; then - echo "$name=success" | tee -a "$report" - else - echo "$name=failed($code)" | tee -a "$report" - failed=1 - fi - } - - check upstream_alignment test "$(git rev-parse origin/main)" = "$(git rev-parse upstream/main)" - check nightly_format cargo +nightly fmt --all -- --check - check whitespace git diff --check upstream/main...HEAD - check clippy cargo clippy --all-targets --all-features -- -D warnings - check all_feature_tests cargo test --all-features - - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] - | select(startswith("__") | not) - | select(. != "local")] | join(",")') - check no_local_tests cargo test -p rmcp --features "$FEATURES" - - check focused_cache_tests cargo test -p rmcp --all-features service::client::tests -- --nocapture - check notification_integration cargo test -p rmcp --all-features --test test_tool_disable_notification -- --nocapture - check cache_hint_tests cargo test -p rmcp --all-features --test test_cache_hints -- --nocapture - check doctests cargo test -p rmcp --doc --all-features - check rustdoc env RUSTDOCFLAGS=-Dwarnings cargo doc -p rmcp --all-features --no-deps - check conformance cargo test --manifest-path conformance/Cargo.toml --all-features - check semver_default cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features default - check semver_non_local cargo semver-checks \ - --package rmcp --baseline-rev upstream/main --release-type minor \ - --only-explicit-features --features "$FEATURES" - check public_api_default cargo public-api --package rmcp -ss diff \ - --deny changed --deny removed --force upstream/main..HEAD - check public_api_non_local cargo public-api --package rmcp \ - --features "$FEATURES" -ss diff --deny changed --deny removed \ - --force upstream/main..HEAD - check coverage cargo llvm-cov -p rmcp --all-features --no-report - check spelling typos - - echo "tested_sha=$(git rev-parse HEAD)" >> "$report" - echo "upstream_main=$(git rev-parse upstream/main)" >> "$report" - if [ "$failed" -eq 0 ]; then - echo "status=success" >> "$report" - else - echo "status=failed" >> "$report" - fi - echo "failed=$failed" >> "$GITHUB_OUTPUT" - exit 0 - - - name: Publish isolated result - run: | - rm .github/workflows/verify-issue-974-isolated.yml - git add -A - git commit -m "ci: record isolated issue 974 verification" - git push origin HEAD:tmp/issue-974-clean-final - - - name: Fail when a check failed - if: steps.matrix.outputs.failed != '0' - run: exit 1 diff --git a/.issue-974-isolated-result.txt b/.issue-974-isolated-result.txt new file mode 100644 index 00000000..09a67d12 --- /dev/null +++ b/.issue-974-isolated-result.txt @@ -0,0 +1,24 @@ +fetch_source=success +cherry_pick=success +fetch_upstream=success +upstream_alignment=success +nightly_format=success +whitespace=success +clippy=failed(101) +all_feature_tests=success +no_local_tests=success +focused_cache_tests=success +notification_integration=success +cache_hint_tests=success +doctests=success +rustdoc=success +conformance=success +semver_default=success +semver_non_local=success +public_api_default=success +public_api_non_local=success +coverage=success +spelling=success +tested_sha=3506966da5a9a8fca5d1023b324a15746cfc415b +upstream_main=3662d20ac27af34e96d6d7285031d54077a6d755 +status=failed