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::McpMethod;
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: &McpMethod) -> bool {
58    matches!(
59        method,
60        McpMethod::Initialize
61            | McpMethod::ToolsList
62            | McpMethod::ResourcesList
63            | McpMethod::ResourcesTemplatesList
64            | McpMethod::PromptsList
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    if pages.len() == 1 {
106        return Some(pages[0].clone());
107    }
108
109    let array_key = method_array_key(method)?;
110
111    let mut merged_array: Vec<Value> = Vec::new();
112    for page in pages {
113        if let Some(arr) = page.get(array_key).and_then(|a| a.as_array()) {
114            merged_array.extend(arr.iter().cloned());
115        }
116    }
117
118    Some(serde_json::json!({ array_key: merged_array }))
119}
120
121/// Diff two schema payloads for a list method.
122///
123/// Compares named items (by their `name` field) and returns granular
124/// changes: added, removed, and modified items.
125///
126/// For methods without named items (e.g., `initialize`), returns a
127/// single "updated" diff if the payloads differ.
128pub fn diff_schema(method: &str, old_payload: &Value, new_payload: &Value) -> Vec<SchemaDiff> {
129    let array_key = match method_array_key(method) {
130        Some(key) => key,
131        None => {
132            // Non-list method (e.g., initialize) — no granular diff.
133            return vec![SchemaDiff {
134                change_type: "updated".to_string(),
135                item_name: None,
136            }];
137        }
138    };
139
140    let item_type = method_item_type(method);
141    let old_items = extract_named_items(old_payload, array_key);
142    let new_items = extract_named_items(new_payload, array_key);
143
144    let mut changes = Vec::new();
145
146    // Find added and modified items.
147    for (name, new_val) in &new_items {
148        match old_items.get(name) {
149            None => changes.push(SchemaDiff {
150                change_type: format!("{item_type}_added"),
151                item_name: Some(name.clone()),
152            }),
153            Some(old_val) if old_val != new_val => changes.push(SchemaDiff {
154                change_type: format!("{item_type}_modified"),
155                item_name: Some(name.clone()),
156            }),
157            _ => {} // unchanged
158        }
159    }
160
161    // Find removed items.
162    for name in old_items.keys() {
163        if !new_items.contains_key(name) {
164            changes.push(SchemaDiff {
165                change_type: format!("{item_type}_removed"),
166                item_name: Some(name.clone()),
167            });
168        }
169    }
170
171    if changes.is_empty() {
172        // Hash changed but no named items differ — structural change.
173        changes.push(SchemaDiff {
174            change_type: "updated".to_string(),
175            item_name: None,
176        });
177    }
178
179    changes
180}
181
182// ── Internal helpers ─────────────────────────────────────────────────
183
184/// Map an MCP list method to the array key in its `result` payload.
185fn method_array_key(method: &str) -> Option<&'static str> {
186    match method {
187        "tools/list" => Some("tools"),
188        "resources/list" => Some("resources"),
189        "resources/templates/list" => Some("resourceTemplates"),
190        "prompts/list" => Some("prompts"),
191        _ => None,
192    }
193}
194
195/// Map an MCP list method to a human-readable item type label used in
196/// change records (e.g., "tool_added", "resource_removed").
197fn method_item_type(method: &str) -> &'static str {
198    match method {
199        "tools/list" => "tool",
200        "resources/list" => "resource",
201        "resources/templates/list" => "resource_template",
202        "prompts/list" => "prompt",
203        _ => "item",
204    }
205}
206
207/// Extract named items from a list payload as a map of name → JSON string.
208///
209/// MCP list items (tools, resources, prompts) have a `name` field that
210/// serves as a stable identifier for diffing.
211fn extract_named_items(payload: &Value, array_key: &str) -> HashMap<String, String> {
212    let mut map = HashMap::new();
213    if let Some(arr) = payload.get(array_key).and_then(|a| a.as_array()) {
214        for item in arr {
215            if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
216                map.insert(name.to_string(), item.to_string());
217            }
218        }
219    }
220    map
221}
222
223// ── Tests ────────────────────────────────────────────────────────────
224
225#[cfg(test)]
226#[allow(non_snake_case)]
227mod tests {
228    use super::*;
229    use serde_json::json;
230
231    // ── is_schema_method ─────────────────────────────────────────────
232
233    #[test]
234    fn is_schema_method__matches_discovery() {
235        assert!(is_schema_method(&McpMethod::Initialize));
236        assert!(is_schema_method(&McpMethod::ToolsList));
237        assert!(is_schema_method(&McpMethod::ResourcesList));
238        assert!(is_schema_method(&McpMethod::ResourcesTemplatesList));
239        assert!(is_schema_method(&McpMethod::PromptsList));
240    }
241
242    #[test]
243    fn is_schema_method__rejects_non_discovery() {
244        assert!(!is_schema_method(&McpMethod::ToolsCall));
245        assert!(!is_schema_method(&McpMethod::ResourcesRead));
246        assert!(!is_schema_method(&McpMethod::PromptsGet));
247        assert!(!is_schema_method(&McpMethod::Ping));
248        assert!(!is_schema_method(&McpMethod::Initialized));
249        assert!(!is_schema_method(&McpMethod::NotificationsToolsListChanged));
250    }
251
252    // ── detect_page_status ───────────────────────────────────────────
253
254    #[test]
255    fn detect_page_status__complete() {
256        let req = json!({"method": "tools/list"});
257        let resp = json!({"result": {"tools": []}});
258        assert_eq!(detect_page_status(&req, &resp), PageStatus::Complete);
259    }
260
261    #[test]
262    fn detect_page_status__first_page() {
263        let req = json!({"method": "tools/list"});
264        let resp = json!({"result": {"tools": [], "nextCursor": "abc"}});
265        assert_eq!(detect_page_status(&req, &resp), PageStatus::FirstPage);
266    }
267
268    #[test]
269    fn detect_page_status__middle_page() {
270        let req = json!({"method": "tools/list", "params": {"cursor": "abc"}});
271        let resp = json!({"result": {"tools": [], "nextCursor": "def"}});
272        assert_eq!(detect_page_status(&req, &resp), PageStatus::MiddlePage);
273    }
274
275    #[test]
276    fn detect_page_status__last_page() {
277        let req = json!({"method": "tools/list", "params": {"cursor": "abc"}});
278        let resp = json!({"result": {"tools": []}});
279        assert_eq!(detect_page_status(&req, &resp), PageStatus::LastPage);
280    }
281
282    // ── merge_pages ──────────────────────────────────────────────────
283
284    #[test]
285    fn merge_pages__single() {
286        let page = json!({"tools": [{"name": "a"}]});
287        let result = merge_pages("tools/list", &[page.clone()]);
288        assert_eq!(result, Some(page));
289    }
290
291    #[test]
292    fn merge_pages__two_pages() {
293        let p1 = json!({"tools": [{"name": "a"}]});
294        let p2 = json!({"tools": [{"name": "b"}]});
295        let result = merge_pages("tools/list", &[p1, p2]).unwrap();
296        let tools = result["tools"].as_array().unwrap();
297        assert_eq!(tools.len(), 2);
298        assert_eq!(tools[0]["name"], "a");
299        assert_eq!(tools[1]["name"], "b");
300    }
301
302    #[test]
303    fn merge_pages__resources() {
304        let p1 = json!({"resources": [{"name": "r1", "uri": "file://a"}]});
305        let p2 = json!({"resources": [{"name": "r2", "uri": "file://b"}]});
306        let result = merge_pages("resources/list", &[p1, p2]).unwrap();
307        assert_eq!(result["resources"].as_array().unwrap().len(), 2);
308    }
309
310    #[test]
311    fn merge_pages__empty() {
312        let result = merge_pages("tools/list", &[]);
313        assert_eq!(result, None);
314    }
315
316    #[test]
317    fn merge_pages__unknown_method_single_returns_as_is() {
318        let p1 = json!({"serverInfo": {"name": "test"}});
319        let result = merge_pages("initialize", &[p1.clone()]);
320        assert_eq!(result, Some(p1));
321    }
322
323    #[test]
324    fn merge_pages__unknown_method_multi_returns_none() {
325        let p1 = json!({"serverInfo": {"name": "v1"}});
326        let p2 = json!({"serverInfo": {"name": "v2"}});
327        let result = merge_pages("initialize", &[p1, p2]);
328        assert_eq!(result, None);
329    }
330
331    // ── diff_schema ──────────────────────────────────────────────────
332
333    #[test]
334    fn diff_schema__tool_added() {
335        let old = json!({"tools": [{"name": "a", "description": "tool a"}]});
336        let new = json!({"tools": [
337            {"name": "a", "description": "tool a"},
338            {"name": "b", "description": "tool b"}
339        ]});
340        let diffs = diff_schema("tools/list", &old, &new);
341        assert_eq!(diffs.len(), 1);
342        assert_eq!(diffs[0].change_type, "tool_added");
343        assert_eq!(diffs[0].item_name.as_deref(), Some("b"));
344    }
345
346    #[test]
347    fn diff_schema__tool_removed() {
348        let old = json!({"tools": [
349            {"name": "a", "description": "tool a"},
350            {"name": "b", "description": "tool b"}
351        ]});
352        let new = json!({"tools": [{"name": "a", "description": "tool a"}]});
353        let diffs = diff_schema("tools/list", &old, &new);
354        assert_eq!(diffs.len(), 1);
355        assert_eq!(diffs[0].change_type, "tool_removed");
356        assert_eq!(diffs[0].item_name.as_deref(), Some("b"));
357    }
358
359    #[test]
360    fn diff_schema__tool_modified() {
361        let old = json!({"tools": [{"name": "a", "description": "old desc"}]});
362        let new = json!({"tools": [{"name": "a", "description": "new desc"}]});
363        let diffs = diff_schema("tools/list", &old, &new);
364        assert_eq!(diffs.len(), 1);
365        assert_eq!(diffs[0].change_type, "tool_modified");
366        assert_eq!(diffs[0].item_name.as_deref(), Some("a"));
367    }
368
369    #[test]
370    fn diff_schema__no_change() {
371        let payload = json!({"tools": [{"name": "a", "description": "tool a"}]});
372        let diffs = diff_schema("tools/list", &payload, &payload);
373        assert_eq!(diffs.len(), 1);
374        assert_eq!(diffs[0].change_type, "updated");
375        assert_eq!(diffs[0].item_name, None);
376    }
377
378    #[test]
379    fn diff_schema__multiple_changes() {
380        let old = json!({"tools": [
381            {"name": "a", "description": "old a"},
382            {"name": "b", "description": "tool b"}
383        ]});
384        let new = json!({"tools": [
385            {"name": "a", "description": "new a"},
386            {"name": "c", "description": "tool c"}
387        ]});
388        let diffs = diff_schema("tools/list", &old, &new);
389        let types: Vec<&str> = diffs.iter().map(|d| d.change_type.as_str()).collect();
390        assert!(types.contains(&"tool_modified")); // a modified
391        assert!(types.contains(&"tool_added")); // c added
392        assert!(types.contains(&"tool_removed")); // b removed
393        assert_eq!(diffs.len(), 3);
394    }
395
396    #[test]
397    fn diff_schema__initialize_returns_updated() {
398        let old = json!({"serverInfo": {"name": "test", "version": "1.0"}});
399        let new = json!({"serverInfo": {"name": "test", "version": "2.0"}});
400        let diffs = diff_schema("initialize", &old, &new);
401        assert_eq!(diffs.len(), 1);
402        assert_eq!(diffs[0].change_type, "updated");
403        assert_eq!(diffs[0].item_name, None);
404    }
405
406    #[test]
407    fn diff_schema__prompts() {
408        let old = json!({"prompts": [{"name": "summarize"}]});
409        let new = json!({"prompts": [{"name": "summarize"}, {"name": "translate"}]});
410        let diffs = diff_schema("prompts/list", &old, &new);
411        assert_eq!(diffs.len(), 1);
412        assert_eq!(diffs[0].change_type, "prompt_added");
413        assert_eq!(diffs[0].item_name.as_deref(), Some("translate"));
414    }
415
416    #[test]
417    fn diff_schema__resources() {
418        let old = json!({"resources": [
419            {"name": "file1", "uri": "file://a"},
420            {"name": "file2", "uri": "file://b"}
421        ]});
422        let new = json!({"resources": [{"name": "file1", "uri": "file://a"}]});
423        let diffs = diff_schema("resources/list", &old, &new);
424        assert_eq!(diffs.len(), 1);
425        assert_eq!(diffs[0].change_type, "resource_removed");
426        assert_eq!(diffs[0].item_name.as_deref(), Some("file2"));
427    }
428
429    // ── method_array_key ─────────────────────────────────────────────
430
431    #[test]
432    fn method_array_key__mapping() {
433        assert_eq!(method_array_key("tools/list"), Some("tools"));
434        assert_eq!(method_array_key("resources/list"), Some("resources"));
435        assert_eq!(
436            method_array_key("resources/templates/list"),
437            Some("resourceTemplates")
438        );
439        assert_eq!(method_array_key("prompts/list"), Some("prompts"));
440        assert_eq!(method_array_key("initialize"), None);
441        assert_eq!(method_array_key("tools/call"), None);
442    }
443}