Skip to main content

mcpr_core/protocol/
schema.rs

1//! MCP schema capture: types, pagination merging, and diff logic.
2//!
3//! This module understands the structure of MCP discovery responses
4//! (`initialize`, `tools/list`, `resources/list`, `prompts/list`,
5//! `resources/templates/list`) and provides:
6//!
7//! - **Pagination detection**: Determine if a response is a single page or
8//!   part of a paginated sequence (MCP cursor-based pagination).
9//! - **Page merging**: Combine paginated responses into a single snapshot.
10//! - **Schema diffing**: Compare two snapshots to detect added, removed,
11//!   and modified items (tools, resources, prompts).
12//!
13//! This is pure protocol logic — no HTTP, no storage, no hashing.
14//! The proxy and storage layers consume these functions.
15
16use std::collections::HashMap;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use super::mcp::{ClientMethod, LifecycleMethod, PromptsMethod, ResourcesMethod, ToolsMethod};
22
23// ── Types ────────────────────────────────────────────────────────────
24
25/// Pagination state for an MCP list response.
26///
27/// Determined by checking `params.cursor` in the request and
28/// `result.nextCursor` in the response.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PageStatus {
32    /// Single-page response (no pagination). This is the common path.
33    Complete,
34    /// First page of a paginated response (no cursor in request, has nextCursor).
35    FirstPage,
36    /// Middle page (has cursor in request and nextCursor in response).
37    MiddlePage,
38    /// Last page (has cursor in request, no nextCursor in response).
39    LastPage,
40}
41
42/// Result of diffing two schema snapshots for a single MCP method.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SchemaDiff {
45    /// Type of change: "tool_added", "tool_removed", "tool_modified",
46    /// "resource_added", "prompt_modified", "updated", etc.
47    pub change_type: String,
48    /// Name of the affected item (e.g., "search_products"). None for
49    /// bulk changes like "updated" or "initial".
50    pub item_name: Option<String>,
51}
52
53// ── Public functions ─────────────────────────────────────────────────
54
55/// Check if an MCP method is a schema discovery method whose response
56/// should be captured.
57pub 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
68/// Determine pagination status from the request body and response body.
69///
70/// MCP pagination uses cursor-based paging:
71/// - Request `params.cursor` present → continuing from a previous page.
72/// - Response `result.nextCursor` present → more pages available.
73pub 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
94/// Merge paginated list responses into a single combined `result` payload.
95///
96/// Each page is the `result` field from a JSON-RPC response. This function
97/// merges the array field (tools, resources, resourceTemplates, prompts)
98/// across all pages into a single value.
99///
100/// Returns `None` if pages is empty or the method has no array key.
101pub fn merge_pages(method: &str, pages: &[Value]) -> Option<Value> {
102    if pages.is_empty() {
103        return None;
104    }
105
106    // List methods (tools/list, resources/list, …) must extract only the
107    // named array so per-request metadata (`_meta`, server-generated
108    // request ids, etc.) does not leak into the hash and produce
109    // phantom versions. Non-list methods (initialize) retain the raw
110    // page — they have no array to project.
111    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    // Sort by `name` so identical item sets in different upstream orders
123    // produce the same payload. Ties (missing or duplicate names) fall back
124    // to canonical JSON so the result is fully deterministic.
125    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
136/// Project a list payload to its content-defining fields for hashing.
137///
138/// Hashing the full stored payload causes phantom version bumps when an
139/// upstream rewords a description or adds optional metadata. This view
140/// keeps only the identifier and the argument contract per item — the
141/// fields that actually define the tool/resource/prompt API — so
142/// version numbers track real API changes.
143///
144/// Returns `None` for methods that don't have a list contract; the
145/// caller decides how to hash those (typically the raw payload).
146///
147/// | Method                       | Kept fields            |
148/// |------------------------------|------------------------|
149/// | `tools/list`                 | `name`, `inputSchema`  |
150/// | `prompts/list`               | `name`, `arguments`    |
151/// | `resources/list`             | `name`, `uri`          |
152/// | `resources/templates/list`   | `name`, `uriTemplate`  |
153pub 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
183/// Diff two schema payloads for a list method.
184///
185/// Compares named items (by their `name` field) and returns granular
186/// changes: added, removed, and modified items.
187///
188/// For methods without named items (e.g., `initialize`), returns a
189/// single "updated" diff if the payloads differ.
190pub 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            // Non-list method (e.g., initialize) — no granular diff.
195            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    // Find added and modified items.
209    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            _ => {} // unchanged
220        }
221    }
222
223    // Find removed items.
224    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        // Hash changed but no named items differ — structural change.
235        changes.push(SchemaDiff {
236            change_type: "updated".to_string(),
237            item_name: None,
238        });
239    }
240
241    changes
242}
243
244// ── Internal helpers ─────────────────────────────────────────────────
245
246/// Map an MCP list method to the array key in its `result` payload.
247fn 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
257/// Map an MCP list method to a human-readable item type label used in
258/// change records (e.g., "tool_added", "resource_removed").
259fn 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
269/// Extract named items from a list payload as a map of name → JSON string.
270///
271/// MCP list items (tools, resources, prompts) have a `name` field that
272/// serves as a stable identifier for diffing.
273fn 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// ── Tests ────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288#[allow(non_snake_case)]
289mod tests {
290    use super::*;
291    use serde_json::json;
292
293    // ── is_schema_method ─────────────────────────────────────────────
294
295    #[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        // Notifications have a separate enum; is_schema_method only
323        // accepts ClientMethod (request-side), so they can't even be
324        // constructed here. That's the type-level guarantee.
325    }
326
327    // ── detect_page_status ───────────────────────────────────────────
328
329    #[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    // ── merge_pages ──────────────────────────────────────────────────
358
359    #[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        // Regression: Study Kit upstream returned 38 tools but produced 138
394        // schema versions because the single-page branch kept the whole
395        // raw result, including `_meta` / `serverInfo` fields that the
396        // server regenerates per request.
397        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        // Regression: upstream returning identical tool sets in different
453        // orders previously produced different hashes and inflated the
454        // version count.
455        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    // ── canonical_hash_view ──────────────────────────────────────────
483
484    #[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    // ── diff_schema ──────────────────────────────────────────────────
588
589    #[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")); // a modified
647        assert!(types.contains(&"tool_added")); // c added
648        assert!(types.contains(&"tool_removed")); // b removed
649        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    // ── method_array_key ─────────────────────────────────────────────
686
687    #[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}