Skip to content

Commit 94f6ab2

Browse files
committed
ci: add deterministic issue 974 source refinements
1 parent ec4d2c5 commit 94f6ab2

1 file changed

Lines changed: 338 additions & 0 deletions

File tree

ci/issue_974_apply.py

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
from pathlib import Path
2+
3+
4+
def replace_once(path: str, old: str, new: str) -> None:
5+
file = Path(path)
6+
text = file.read_text()
7+
count = text.count(old)
8+
if count != 1:
9+
raise SystemExit(f"{path}: expected one match, found {count}\n--- old ---\n{old}")
10+
file.write_text(text.replace(old, new, 1))
11+
12+
13+
replace_once(
14+
"crates/rmcp/src/service/client.rs",
15+
'''fn list_response_cache_key(prefix: &str, params: &Option<PaginatedRequestParams>) -> String {
16+
let cursor = params.as_ref().and_then(|params| params.cursor.as_deref());
17+
let cursor = serde_json::to_string(&cursor)
18+
.expect("serializing an optional pagination cursor cannot fail");
19+
format!("{prefix}{cursor}")
20+
}
21+
22+
fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option<String> {
23+
if params.input_responses.is_some() || params.request_state.is_some() {
24+
return None;
25+
}
26+
Some(resource_read_cache_key_for_uri(&params.uri))
27+
}
28+
29+
fn resource_read_cache_key_for_uri(uri: &str) -> String {
30+
let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail");
31+
format!("{RESOURCE_READ_CACHE_PREFIX}{uri}")
32+
}''',
33+
'''fn list_response_cache_key(prefix: &str, params: &Option<PaginatedRequestParams>) -> String {
34+
let params = serde_json::to_string(params)
35+
.expect("serializing pagination request parameters cannot fail");
36+
format!("{prefix}{params}")
37+
}
38+
39+
fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option<String> {
40+
if params.input_responses.is_some() || params.request_state.is_some() {
41+
return None;
42+
}
43+
let serialized = serde_json::to_string(params)
44+
.expect("serializing resource request parameters cannot fail");
45+
Some(format!(
46+
"{}{serialized}",
47+
resource_read_cache_prefix_for_uri(&params.uri)
48+
))
49+
}
50+
51+
fn resource_read_cache_prefix_for_uri(uri: &str) -> String {
52+
let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail");
53+
format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:")
54+
}''',
55+
)
56+
57+
replace_once(
58+
"crates/rmcp/src/service/client.rs",
59+
''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) {
60+
self.invalidate_cached_response(&resource_read_cache_key_for_uri(uri))
61+
.await;
62+
}''',
63+
''' pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) {
64+
self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri))
65+
.await;
66+
}''',
67+
)
68+
69+
replace_once(
70+
"crates/rmcp/src/service/client/cache.rs",
71+
''' let config_changed = cache.config != config;
72+
let partition_changed = cache.config.private_partition != config.private_partition;
73+
cache.config = config;
74+
if config_changed {
75+
cache.generation = cache.generation.wrapping_add(1);
76+
}
77+
if !cache.config.enabled {
78+
cache.entries.clear();
79+
} else if partition_changed {
80+
cache
81+
.entries
82+
.retain(|_, entry| entry.scope == CacheScope::Public);
83+
}
84+
cache.trim_to_limit();''',
85+
''' let config_changed = cache.config != config;
86+
let partition_changed = cache.config.private_partition != config.private_partition;
87+
let ttl_policy_changed = cache.config.default_ttl != config.default_ttl
88+
|| cache.config.max_ttl != config.max_ttl;
89+
cache.config = config;
90+
if config_changed {
91+
cache.generation = cache.generation.wrapping_add(1);
92+
}
93+
if !cache.config.enabled || ttl_policy_changed {
94+
cache.entries.clear();
95+
} else if partition_changed {
96+
cache
97+
.entries
98+
.retain(|_, entry| entry.scope == CacheScope::Public);
99+
}
100+
cache.trim_to_limit();''',
101+
)
102+
103+
replace_once(
104+
"crates/rmcp/src/service/client.rs",
105+
''' #[test]
106+
fn paginated_pages_have_independent_cache_keys() {
107+
let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into())));
108+
let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into())));
109+
110+
assert_ne!(
111+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first),
112+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second)
113+
);
114+
}''',
115+
''' #[test]
116+
fn paginated_pages_have_independent_cache_keys() {
117+
let first = Some(PaginatedRequestParams::default().with_cursor(Some("page-a".into())));
118+
let second = Some(PaginatedRequestParams::default().with_cursor(Some("page-b".into())));
119+
120+
assert_ne!(
121+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first),
122+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second)
123+
);
124+
}
125+
126+
#[test]
127+
fn result_affecting_metadata_is_part_of_cache_keys() {
128+
let mut first_meta = crate::model::Meta::new();
129+
first_meta.insert("variant".into(), serde_json::json!("a"));
130+
let mut second_meta = crate::model::Meta::new();
131+
second_meta.insert("variant".into(), serde_json::json!("b"));
132+
133+
let first_page = Some(PaginatedRequestParams {
134+
meta: Some(first_meta.clone()),
135+
cursor: Some("page".into()),
136+
});
137+
let second_page = Some(PaginatedRequestParams {
138+
meta: Some(second_meta.clone()),
139+
cursor: Some("page".into()),
140+
});
141+
assert_ne!(
142+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &first_page),
143+
list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &second_page)
144+
);
145+
146+
let first_resource =
147+
ReadResourceRequestParams::new("file:///example").with_meta(first_meta);
148+
let second_resource =
149+
ReadResourceRequestParams::new("file:///example").with_meta(second_meta);
150+
assert_ne!(
151+
resource_read_cache_key(&first_resource),
152+
resource_read_cache_key(&second_resource)
153+
);
154+
}''',
155+
)
156+
157+
replace_once(
158+
"crates/rmcp/src/service/client.rs",
159+
''' #[tokio::test]
160+
async fn resource_update_invalidates_only_the_matching_uri() {
161+
let peer = disconnected_peer();
162+
let first_key = resource_read_cache_key_for_uri("file:///first");
163+
let second_key = resource_read_cache_key_for_uri("file:///second");
164+
for key in [&first_key, &second_key] {
165+
peer.cache_response(
166+
key.clone(),
167+
ServerResult::ReadResourceResult(
168+
ReadResourceResult::new(Vec::new())
169+
.with_ttl_ms(5_000)
170+
.with_cache_scope(CacheScope::Private),
171+
),
172+
Some(5_000),
173+
Some(CacheScope::Private),
174+
)
175+
.await;
176+
}
177+
178+
peer.invalidate_resource_read_cache("file:///first").await;
179+
180+
assert!(peer.cached_response(&first_key).await.is_none());
181+
assert!(peer.cached_response(&second_key).await.is_some());
182+
}''',
183+
''' #[tokio::test]
184+
async fn resource_update_invalidates_every_metadata_variant_for_the_matching_uri() {
185+
let peer = disconnected_peer();
186+
let first_plain = ReadResourceRequestParams::new("file:///first");
187+
let mut meta = crate::model::Meta::new();
188+
meta.insert("variant".into(), serde_json::json!("a"));
189+
let first_with_meta = ReadResourceRequestParams::new("file:///first").with_meta(meta);
190+
let second = ReadResourceRequestParams::new("file:///second");
191+
let first_plain_key = resource_read_cache_key(&first_plain).unwrap();
192+
let first_meta_key = resource_read_cache_key(&first_with_meta).unwrap();
193+
let second_key = resource_read_cache_key(&second).unwrap();
194+
for key in [&first_plain_key, &first_meta_key, &second_key] {
195+
peer.cache_response(
196+
key.clone(),
197+
ServerResult::ReadResourceResult(
198+
ReadResourceResult::new(Vec::new())
199+
.with_ttl_ms(5_000)
200+
.with_cache_scope(CacheScope::Private),
201+
),
202+
Some(5_000),
203+
Some(CacheScope::Private),
204+
)
205+
.await;
206+
}
207+
208+
peer.invalidate_resource_read_cache("file:///first").await;
209+
210+
assert!(peer.cached_response(&first_plain_key).await.is_none());
211+
assert!(peer.cached_response(&first_meta_key).await.is_none());
212+
assert!(peer.cached_response(&second_key).await.is_some());
213+
}''',
214+
)
215+
216+
replace_once(
217+
"crates/rmcp/src/service/client.rs",
218+
''' let first = resource_read_cache_key_for_uri("file:///first");
219+
let second = resource_read_cache_key_for_uri("file:///second");''',
220+
''' let first = resource_read_cache_key(&ReadResourceRequestParams::new("file:///first"))
221+
.unwrap();
222+
let second = resource_read_cache_key(&ReadResourceRequestParams::new("file:///second"))
223+
.unwrap();''',
224+
)
225+
226+
replace_once(
227+
"crates/rmcp/src/service/client.rs",
228+
''' #[tokio::test]
229+
async fn private_entries_are_isolated_between_client_peers() {''',
230+
''' #[tokio::test]
231+
async fn changing_ttl_policy_invalidates_existing_entries() {
232+
let peer = disconnected_peer();
233+
let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None);
234+
peer.cache_response(
235+
key.clone(),
236+
ServerResult::ListToolsResult(tools_result(Some(60_000), Some(CacheScope::Public))),
237+
Some(60_000),
238+
Some(CacheScope::Public),
239+
)
240+
.await;
241+
assert!(peer.cached_response(&key).await.is_some());
242+
243+
peer.set_response_cache_config(
244+
ClientCacheConfig::default().with_max_ttl(Duration::from_millis(1)),
245+
)
246+
.await;
247+
248+
assert!(peer.cached_response(&key).await.is_none());
249+
}
250+
251+
#[tokio::test]
252+
async fn private_entries_are_isolated_between_client_peers() {''',
253+
)
254+
255+
path = "crates/rmcp/tests/test_tool_disable_notification.rs"
256+
replace_once(
257+
path,
258+
'model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},',
259+
'model::{CacheScope, CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},',
260+
)
261+
replace_once(
262+
path,
263+
' trigger_enable: Arc<Notify>,\n}',
264+
' trigger_enable: Arc<Notify>,\n list_count: Arc<AtomicUsize>,\n}',
265+
)
266+
replace_once(
267+
path,
268+
' trigger_enable: Arc::new(Notify::new()),\n }',
269+
' trigger_enable: Arc::new(Notify::new()),\n list_count: Arc::new(AtomicUsize::new(0)),\n }',
270+
)
271+
replace_once(
272+
path,
273+
''' let router = self.router.read().await;
274+
Ok(rmcp::model::ListToolsResult {
275+
tools: router.list_all(),
276+
..Default::default()
277+
})''',
278+
''' self.list_count.fetch_add(1, Ordering::SeqCst);
279+
let router = self.router.read().await;
280+
Ok(rmcp::model::ListToolsResult {
281+
tools: router.list_all(),
282+
..Default::default()
283+
}
284+
.with_ttl_ms(60_000)
285+
.with_cache_scope(CacheScope::Public))''',
286+
)
287+
replace_once(
288+
path,
289+
' let trigger_enable = server.trigger_enable.clone();\n',
290+
' let trigger_enable = server.trigger_enable.clone();\n let list_count = server.list_count.clone();\n',
291+
)
292+
replace_once(
293+
path,
294+
''' let tools = client_service.peer().list_tools(None).await.unwrap();
295+
assert_eq!(tools.tools.len(), 2);
296+
297+
trigger_disable.notify_one();''',
298+
''' let tools = client_service.peer().list_tools(None).await.unwrap();
299+
assert_eq!(tools.tools.len(), 2);
300+
assert_eq!(list_count.load(Ordering::SeqCst), 1);
301+
302+
let cached_tools = client_service.peer().list_tools(None).await.unwrap();
303+
assert_eq!(cached_tools.tools.len(), 2);
304+
assert_eq!(list_count.load(Ordering::SeqCst), 1);
305+
306+
trigger_disable.notify_one();''',
307+
)
308+
replace_once(
309+
path,
310+
''' assert_eq!(tools.tools.len(), 1);
311+
assert_eq!(tools.tools[0].name, "tool_b");''',
312+
''' assert_eq!(tools.tools.len(), 1);
313+
assert_eq!(tools.tools[0].name, "tool_b");
314+
assert_eq!(list_count.load(Ordering::SeqCst), 2);''',
315+
)
316+
replace_once(
317+
path,
318+
''' let tools = client_service.peer().list_tools(None).await.unwrap();
319+
assert_eq!(tools.tools.len(), 2);
320+
321+
client_service.cancel().await.unwrap();''',
322+
''' let tools = client_service.peer().list_tools(None).await.unwrap();
323+
assert_eq!(tools.tools.len(), 2);
324+
assert_eq!(list_count.load(Ordering::SeqCst), 3);
325+
326+
client_service.cancel().await.unwrap();''',
327+
)
328+
329+
replace_once(
330+
"docs/CLIENT_CACHING.md",
331+
'''Cache keys include the method and result-affecting parameters: the cursor for
332+
paginated list methods and the URI for resource reads. MRTR retries containing
333+
`inputResponses` or `requestState` are never cached.''',
334+
'''Cache keys include the method and all currently result-affecting parameters: the
335+
cursor and `_meta` for paginated list methods, and the URI plus `_meta` for resource
336+
reads. MRTR retries containing `inputResponses` or `requestState` are never cached.
337+
A response that omits `cacheScope` is treated as private rather than made shareable.''',
338+
)

0 commit comments

Comments
 (0)