1use std::collections::HashMap;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use super::McpMethod;
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: &McpMethod) -> bool {
58 matches!(
59 method,
60 McpMethod::Initialize
61 | McpMethod::ToolsList
62 | McpMethod::ResourcesList
63 | McpMethod::ResourcesTemplatesList
64 | McpMethod::PromptsList
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 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
121pub 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 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 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 _ => {} }
159 }
160
161 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 changes.push(SchemaDiff {
174 change_type: "updated".to_string(),
175 item_name: None,
176 });
177 }
178
179 changes
180}
181
182fn 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
195fn 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
207fn 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#[cfg(test)]
226#[allow(non_snake_case)]
227mod tests {
228 use super::*;
229 use serde_json::json;
230
231 #[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 #[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 #[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 #[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")); assert!(types.contains(&"tool_added")); assert!(types.contains(&"tool_removed")); 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 #[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}