1use std::collections::HashMap;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use super::mcp::{ClientMethod, LifecycleMethod, PromptsMethod, ResourcesMethod, ToolsMethod};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PageStatus {
32 Complete,
34 FirstPage,
36 MiddlePage,
38 LastPage,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SchemaDiff {
45 pub change_type: String,
48 pub item_name: Option<String>,
51}
52
53pub fn is_schema_method(method: &ClientMethod) -> bool {
58 matches!(
59 method,
60 ClientMethod::Lifecycle(LifecycleMethod::Initialize)
61 | ClientMethod::Tools(ToolsMethod::List)
62 | ClientMethod::Resources(ResourcesMethod::List)
63 | ClientMethod::Resources(ResourcesMethod::TemplatesList)
64 | ClientMethod::Prompts(PromptsMethod::List)
65 )
66}
67
68pub fn detect_page_status(request_body: &Value, response_body: &Value) -> PageStatus {
74 let req_has_cursor = request_body
75 .get("params")
76 .and_then(|p| p.get("cursor"))
77 .and_then(|c| c.as_str())
78 .is_some();
79
80 let resp_has_next_cursor = response_body
81 .get("result")
82 .and_then(|r| r.get("nextCursor"))
83 .and_then(|c| c.as_str())
84 .is_some();
85
86 match (req_has_cursor, resp_has_next_cursor) {
87 (false, false) => PageStatus::Complete,
88 (false, true) => PageStatus::FirstPage,
89 (true, true) => PageStatus::MiddlePage,
90 (true, false) => PageStatus::LastPage,
91 }
92}
93
94pub fn merge_pages(method: &str, pages: &[Value]) -> Option<Value> {
102 if pages.is_empty() {
103 return None;
104 }
105
106 let Some(array_key) = method_array_key(method) else {
112 return (pages.len() == 1).then(|| pages[0].clone());
113 };
114
115 let mut merged_array: Vec<Value> = Vec::new();
116 for page in pages {
117 if let Some(arr) = page.get(array_key).and_then(|a| a.as_array()) {
118 merged_array.extend(arr.iter().cloned());
119 }
120 }
121
122 merged_array.sort_by(|a, b| {
126 let a_name = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
127 let b_name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
128 a_name
129 .cmp(b_name)
130 .then_with(|| a.to_string().cmp(&b.to_string()))
131 });
132
133 Some(serde_json::json!({ array_key: merged_array }))
134}
135
136pub fn canonical_hash_view(method: &str, payload: &Value) -> Option<Value> {
154 let (array_key, item_keys): (&str, &[&str]) = match method {
155 "tools/list" => ("tools", &["name", "inputSchema"]),
156 "prompts/list" => ("prompts", &["name", "arguments"]),
157 "resources/list" => ("resources", &["name", "uri"]),
158 "resources/templates/list" => ("resourceTemplates", &["name", "uriTemplate"]),
159 _ => return None,
160 };
161 let projected: Vec<Value> = payload
162 .get(array_key)
163 .and_then(|v| v.as_array())
164 .map(|arr| {
165 arr.iter()
166 .map(|item| project_keys(item, item_keys))
167 .collect()
168 })
169 .unwrap_or_default();
170 Some(serde_json::json!({ array_key: projected }))
171}
172
173fn project_keys(item: &Value, keys: &[&str]) -> Value {
174 let mut obj = serde_json::Map::new();
175 for k in keys {
176 if let Some(v) = item.get(*k) {
177 obj.insert((*k).to_string(), v.clone());
178 }
179 }
180 Value::Object(obj)
181}
182
183pub fn diff_schema(method: &str, old_payload: &Value, new_payload: &Value) -> Vec<SchemaDiff> {
191 let array_key = match method_array_key(method) {
192 Some(key) => key,
193 None => {
194 return vec![SchemaDiff {
196 change_type: "updated".to_string(),
197 item_name: None,
198 }];
199 }
200 };
201
202 let item_type = method_item_type(method);
203 let old_items = extract_named_items(old_payload, array_key);
204 let new_items = extract_named_items(new_payload, array_key);
205
206 let mut changes = Vec::new();
207
208 for (name, new_val) in &new_items {
210 match old_items.get(name) {
211 None => changes.push(SchemaDiff {
212 change_type: format!("{item_type}_added"),
213 item_name: Some(name.clone()),
214 }),
215 Some(old_val) if old_val != new_val => changes.push(SchemaDiff {
216 change_type: format!("{item_type}_modified"),
217 item_name: Some(name.clone()),
218 }),
219 _ => {} }
221 }
222
223 for name in old_items.keys() {
225 if !new_items.contains_key(name) {
226 changes.push(SchemaDiff {
227 change_type: format!("{item_type}_removed"),
228 item_name: Some(name.clone()),
229 });
230 }
231 }
232
233 if changes.is_empty() {
234 changes.push(SchemaDiff {
236 change_type: "updated".to_string(),
237 item_name: None,
238 });
239 }
240
241 changes
242}
243
244fn method_array_key(method: &str) -> Option<&'static str> {
248 match method {
249 "tools/list" => Some("tools"),
250 "resources/list" => Some("resources"),
251 "resources/templates/list" => Some("resourceTemplates"),
252 "prompts/list" => Some("prompts"),
253 _ => None,
254 }
255}
256
257fn method_item_type(method: &str) -> &'static str {
260 match method {
261 "tools/list" => "tool",
262 "resources/list" => "resource",
263 "resources/templates/list" => "resource_template",
264 "prompts/list" => "prompt",
265 _ => "item",
266 }
267}
268
269fn extract_named_items(payload: &Value, array_key: &str) -> HashMap<String, String> {
274 let mut map = HashMap::new();
275 if let Some(arr) = payload.get(array_key).and_then(|a| a.as_array()) {
276 for item in arr {
277 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
278 map.insert(name.to_string(), item.to_string());
279 }
280 }
281 }
282 map
283}
284
285#[cfg(test)]
288#[allow(non_snake_case)]
289mod tests {
290 use super::*;
291 use serde_json::json;
292
293 #[test]
296 fn is_schema_method__matches_discovery() {
297 assert!(is_schema_method(&ClientMethod::Lifecycle(
298 LifecycleMethod::Initialize
299 )));
300 assert!(is_schema_method(&ClientMethod::Tools(ToolsMethod::List)));
301 assert!(is_schema_method(&ClientMethod::Resources(
302 ResourcesMethod::List
303 )));
304 assert!(is_schema_method(&ClientMethod::Resources(
305 ResourcesMethod::TemplatesList
306 )));
307 assert!(is_schema_method(&ClientMethod::Prompts(
308 PromptsMethod::List
309 )));
310 }
311
312 #[test]
313 fn is_schema_method__rejects_non_discovery() {
314 assert!(!is_schema_method(&ClientMethod::Tools(ToolsMethod::Call)));
315 assert!(!is_schema_method(&ClientMethod::Resources(
316 ResourcesMethod::Read
317 )));
318 assert!(!is_schema_method(&ClientMethod::Prompts(
319 PromptsMethod::Get
320 )));
321 assert!(!is_schema_method(&ClientMethod::Ping));
322 }
326
327 #[test]
330 fn detect_page_status__complete() {
331 let req = json!({"method": "tools/list"});
332 let resp = json!({"result": {"tools": []}});
333 assert_eq!(detect_page_status(&req, &resp), PageStatus::Complete);
334 }
335
336 #[test]
337 fn detect_page_status__first_page() {
338 let req = json!({"method": "tools/list"});
339 let resp = json!({"result": {"tools": [], "nextCursor": "abc"}});
340 assert_eq!(detect_page_status(&req, &resp), PageStatus::FirstPage);
341 }
342
343 #[test]
344 fn detect_page_status__middle_page() {
345 let req = json!({"method": "tools/list", "params": {"cursor": "abc"}});
346 let resp = json!({"result": {"tools": [], "nextCursor": "def"}});
347 assert_eq!(detect_page_status(&req, &resp), PageStatus::MiddlePage);
348 }
349
350 #[test]
351 fn detect_page_status__last_page() {
352 let req = json!({"method": "tools/list", "params": {"cursor": "abc"}});
353 let resp = json!({"result": {"tools": []}});
354 assert_eq!(detect_page_status(&req, &resp), PageStatus::LastPage);
355 }
356
357 #[test]
360 fn merge_pages__single() {
361 let page = json!({"tools": [{"name": "a"}]});
362 let result = merge_pages("tools/list", std::slice::from_ref(&page));
363 assert_eq!(result, Some(page));
364 }
365
366 #[test]
367 fn merge_pages__two_pages() {
368 let p1 = json!({"tools": [{"name": "a"}]});
369 let p2 = json!({"tools": [{"name": "b"}]});
370 let result = merge_pages("tools/list", &[p1, p2]).unwrap();
371 let tools = result["tools"].as_array().unwrap();
372 assert_eq!(tools.len(), 2);
373 assert_eq!(tools[0]["name"], "a");
374 assert_eq!(tools[1]["name"], "b");
375 }
376
377 #[test]
378 fn merge_pages__resources() {
379 let p1 = json!({"resources": [{"name": "r1", "uri": "file://a"}]});
380 let p2 = json!({"resources": [{"name": "r2", "uri": "file://b"}]});
381 let result = merge_pages("resources/list", &[p1, p2]).unwrap();
382 assert_eq!(result["resources"].as_array().unwrap().len(), 2);
383 }
384
385 #[test]
386 fn merge_pages__empty() {
387 let result = merge_pages("tools/list", &[]);
388 assert_eq!(result, None);
389 }
390
391 #[test]
392 fn merge_pages__single_strips_volatile_metadata() {
393 let p1 = json!({
398 "tools": [{"name": "a"}],
399 "_meta": {"requestId": "req-1"},
400 "serverInfo": {"generatedAt": "2026-04-19T00:00:00Z"}
401 });
402 let p2 = json!({
403 "tools": [{"name": "a"}],
404 "_meta": {"requestId": "req-2"},
405 "serverInfo": {"generatedAt": "2026-04-19T00:00:05Z"}
406 });
407 let r1 = merge_pages("tools/list", &[p1]).unwrap();
408 let r2 = merge_pages("tools/list", &[p2]).unwrap();
409 assert_eq!(r1, r2, "per-request metadata must not reach the hash");
410 assert_eq!(r1, json!({"tools": [{"name": "a"}]}));
411 }
412
413 #[test]
414 fn merge_pages__single_missing_array_key_yields_empty_array() {
415 let p1 = json!({"_meta": {"requestId": "x"}});
416 let result = merge_pages("tools/list", &[p1]).unwrap();
417 assert_eq!(result, json!({"tools": []}));
418 }
419
420 #[test]
421 fn merge_pages__unknown_method_single_returns_as_is() {
422 let p1 = json!({"serverInfo": {"name": "test"}});
423 let result = merge_pages("initialize", std::slice::from_ref(&p1));
424 assert_eq!(result, Some(p1));
425 }
426
427 #[test]
428 fn merge_pages__unknown_method_multi_returns_none() {
429 let p1 = json!({"serverInfo": {"name": "v1"}});
430 let p2 = json!({"serverInfo": {"name": "v2"}});
431 let result = merge_pages("initialize", &[p1, p2]);
432 assert_eq!(result, None);
433 }
434
435 #[test]
436 fn merge_pages__sorts_items_by_name() {
437 let page = json!({"tools": [
438 {"name": "c"}, {"name": "a"}, {"name": "b"}
439 ]});
440 let result = merge_pages("tools/list", &[page]).unwrap();
441 let names: Vec<&str> = result["tools"]
442 .as_array()
443 .unwrap()
444 .iter()
445 .map(|t| t["name"].as_str().unwrap())
446 .collect();
447 assert_eq!(names, vec!["a", "b", "c"]);
448 }
449
450 #[test]
451 fn merge_pages__same_set_in_different_orders_is_equal() {
452 let p1 = json!({"tools": [{"name": "a"}, {"name": "b"}, {"name": "c"}]});
456 let p2 = json!({"tools": [{"name": "c"}, {"name": "a"}, {"name": "b"}]});
457 assert_eq!(
458 merge_pages("tools/list", &[p1]),
459 merge_pages("tools/list", &[p2]),
460 );
461 }
462
463 #[test]
464 fn merge_pages__preserves_description_for_display() {
465 let page = json!({"tools": [
466 {"name": "a", "description": "do thing", "inputSchema": {"type": "object"}}
467 ]});
468 let result = merge_pages("tools/list", &[page]).unwrap();
469 assert_eq!(result["tools"][0]["description"], "do thing");
470 }
471
472 #[test]
473 fn merge_pages__items_without_name_break_ties_by_canonical_json() {
474 let p1 = json!({"tools": [{"foo": "1"}, {"foo": "2"}]});
475 let p2 = json!({"tools": [{"foo": "2"}, {"foo": "1"}]});
476 assert_eq!(
477 merge_pages("tools/list", &[p1]),
478 merge_pages("tools/list", &[p2]),
479 );
480 }
481
482 #[test]
485 fn canonical_hash_view__tool_keeps_only_name_and_input_schema() {
486 let payload = json!({"tools": [{
487 "name": "search",
488 "description": "human text",
489 "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
490 "annotations": {"readOnlyHint": true}
491 }]});
492 let view = canonical_hash_view("tools/list", &payload).unwrap();
493 assert_eq!(
494 view,
495 json!({"tools": [{
496 "name": "search",
497 "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}
498 }]}),
499 );
500 }
501
502 #[test]
503 fn canonical_hash_view__resource_keeps_only_name_and_uri() {
504 let payload = json!({"resources": [{
505 "name": "r1",
506 "uri": "file://a",
507 "description": "desc",
508 "mimeType": "text/plain"
509 }]});
510 let view = canonical_hash_view("resources/list", &payload).unwrap();
511 assert_eq!(
512 view,
513 json!({"resources": [{"name": "r1", "uri": "file://a"}]}),
514 );
515 }
516
517 #[test]
518 fn canonical_hash_view__prompt_keeps_only_name_and_arguments() {
519 let payload = json!({"prompts": [{
520 "name": "summarize",
521 "description": "summarizes text",
522 "arguments": [{"name": "topic", "required": true}]
523 }]});
524 let view = canonical_hash_view("prompts/list", &payload).unwrap();
525 assert_eq!(
526 view,
527 json!({"prompts": [{
528 "name": "summarize",
529 "arguments": [{"name": "topic", "required": true}]
530 }]}),
531 );
532 }
533
534 #[test]
535 fn canonical_hash_view__resource_template_keeps_name_and_uri_template() {
536 let payload = json!({"resourceTemplates": [{
537 "name": "doc",
538 "uriTemplate": "doc://{id}",
539 "description": "any doc",
540 "mimeType": "text/markdown"
541 }]});
542 let view = canonical_hash_view("resources/templates/list", &payload).unwrap();
543 assert_eq!(
544 view,
545 json!({"resourceTemplates": [{"name": "doc", "uriTemplate": "doc://{id}"}]}),
546 );
547 }
548
549 #[test]
550 fn canonical_hash_view__description_only_change_is_invisible() {
551 let p1 = json!({"tools": [{"name": "a", "description": "old", "inputSchema": {}}]});
552 let p2 = json!({"tools": [{"name": "a", "description": "new", "inputSchema": {}}]});
553 assert_eq!(
554 canonical_hash_view("tools/list", &p1),
555 canonical_hash_view("tools/list", &p2),
556 );
557 }
558
559 #[test]
560 fn canonical_hash_view__input_schema_change_is_visible() {
561 let p1 = json!({"tools": [{"name": "a", "inputSchema": {"type": "object"}}]});
562 let p2 = json!({"tools": [{
563 "name": "a",
564 "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}
565 }]});
566 assert_ne!(
567 canonical_hash_view("tools/list", &p1),
568 canonical_hash_view("tools/list", &p2),
569 );
570 }
571
572 #[test]
573 fn canonical_hash_view__non_list_method_returns_none() {
574 let payload = json!({"serverInfo": {"name": "test", "version": "1.0"}});
575 assert_eq!(canonical_hash_view("initialize", &payload), None);
576 }
577
578 #[test]
579 fn canonical_hash_view__missing_array_key_yields_empty_array() {
580 let payload = json!({"_meta": {"requestId": "x"}});
581 assert_eq!(
582 canonical_hash_view("tools/list", &payload),
583 Some(json!({"tools": []})),
584 );
585 }
586
587 #[test]
590 fn diff_schema__tool_added() {
591 let old = json!({"tools": [{"name": "a", "description": "tool a"}]});
592 let new = json!({"tools": [
593 {"name": "a", "description": "tool a"},
594 {"name": "b", "description": "tool b"}
595 ]});
596 let diffs = diff_schema("tools/list", &old, &new);
597 assert_eq!(diffs.len(), 1);
598 assert_eq!(diffs[0].change_type, "tool_added");
599 assert_eq!(diffs[0].item_name.as_deref(), Some("b"));
600 }
601
602 #[test]
603 fn diff_schema__tool_removed() {
604 let old = json!({"tools": [
605 {"name": "a", "description": "tool a"},
606 {"name": "b", "description": "tool b"}
607 ]});
608 let new = json!({"tools": [{"name": "a", "description": "tool a"}]});
609 let diffs = diff_schema("tools/list", &old, &new);
610 assert_eq!(diffs.len(), 1);
611 assert_eq!(diffs[0].change_type, "tool_removed");
612 assert_eq!(diffs[0].item_name.as_deref(), Some("b"));
613 }
614
615 #[test]
616 fn diff_schema__tool_modified() {
617 let old = json!({"tools": [{"name": "a", "description": "old desc"}]});
618 let new = json!({"tools": [{"name": "a", "description": "new desc"}]});
619 let diffs = diff_schema("tools/list", &old, &new);
620 assert_eq!(diffs.len(), 1);
621 assert_eq!(diffs[0].change_type, "tool_modified");
622 assert_eq!(diffs[0].item_name.as_deref(), Some("a"));
623 }
624
625 #[test]
626 fn diff_schema__no_change() {
627 let payload = json!({"tools": [{"name": "a", "description": "tool a"}]});
628 let diffs = diff_schema("tools/list", &payload, &payload);
629 assert_eq!(diffs.len(), 1);
630 assert_eq!(diffs[0].change_type, "updated");
631 assert_eq!(diffs[0].item_name, None);
632 }
633
634 #[test]
635 fn diff_schema__multiple_changes() {
636 let old = json!({"tools": [
637 {"name": "a", "description": "old a"},
638 {"name": "b", "description": "tool b"}
639 ]});
640 let new = json!({"tools": [
641 {"name": "a", "description": "new a"},
642 {"name": "c", "description": "tool c"}
643 ]});
644 let diffs = diff_schema("tools/list", &old, &new);
645 let types: Vec<&str> = diffs.iter().map(|d| d.change_type.as_str()).collect();
646 assert!(types.contains(&"tool_modified")); assert!(types.contains(&"tool_added")); assert!(types.contains(&"tool_removed")); assert_eq!(diffs.len(), 3);
650 }
651
652 #[test]
653 fn diff_schema__initialize_returns_updated() {
654 let old = json!({"serverInfo": {"name": "test", "version": "1.0"}});
655 let new = json!({"serverInfo": {"name": "test", "version": "2.0"}});
656 let diffs = diff_schema("initialize", &old, &new);
657 assert_eq!(diffs.len(), 1);
658 assert_eq!(diffs[0].change_type, "updated");
659 assert_eq!(diffs[0].item_name, None);
660 }
661
662 #[test]
663 fn diff_schema__prompts() {
664 let old = json!({"prompts": [{"name": "summarize"}]});
665 let new = json!({"prompts": [{"name": "summarize"}, {"name": "translate"}]});
666 let diffs = diff_schema("prompts/list", &old, &new);
667 assert_eq!(diffs.len(), 1);
668 assert_eq!(diffs[0].change_type, "prompt_added");
669 assert_eq!(diffs[0].item_name.as_deref(), Some("translate"));
670 }
671
672 #[test]
673 fn diff_schema__resources() {
674 let old = json!({"resources": [
675 {"name": "file1", "uri": "file://a"},
676 {"name": "file2", "uri": "file://b"}
677 ]});
678 let new = json!({"resources": [{"name": "file1", "uri": "file://a"}]});
679 let diffs = diff_schema("resources/list", &old, &new);
680 assert_eq!(diffs.len(), 1);
681 assert_eq!(diffs[0].change_type, "resource_removed");
682 assert_eq!(diffs[0].item_name.as_deref(), Some("file2"));
683 }
684
685 #[test]
688 fn method_array_key__mapping() {
689 assert_eq!(method_array_key("tools/list"), Some("tools"));
690 assert_eq!(method_array_key("resources/list"), Some("resources"));
691 assert_eq!(
692 method_array_key("resources/templates/list"),
693 Some("resourceTemplates")
694 );
695 assert_eq!(method_array_key("prompts/list"), Some("prompts"));
696 assert_eq!(method_array_key("initialize"), None);
697 assert_eq!(method_array_key("tools/call"), None);
698 }
699}