Skip to main content

victauri_browser/
mcp_server.rs

1use rmcp::model::{
2    CallToolRequestParams, CallToolResult, Content, ListToolsResult, PaginatedRequestParams,
3    ServerCapabilities, ServerInfo, Tool,
4};
5use rmcp::service::RequestContext;
6use rmcp::{ErrorData, RoleServer, ServerHandler};
7use serde_json::json;
8
9use crate::mcp_handler::VictauriBrowserHandler;
10
11const SERVER_INSTRUCTIONS: &str = "Victauri Browser — MCP inspection for any website via Chrome \
12extension. Tools: eval_js, dom_snapshot, find_elements, interact, input, inspect, css, logs, \
13storage, navigate, wait_for, assert_semantic, recording, screenshot, tabs, page_info, cookies, \
14get_diagnostics, get_plugin_info, get_memory_stats.";
15
16impl ServerHandler for VictauriBrowserHandler {
17    fn get_info(&self) -> ServerInfo {
18        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
19            .with_instructions(SERVER_INSTRUCTIONS)
20    }
21
22    async fn list_tools(
23        &self,
24        _request: Option<PaginatedRequestParams>,
25        _context: RequestContext<RoleServer>,
26    ) -> Result<ListToolsResult, ErrorData> {
27        let tools = build_tool_definitions();
28        Ok(ListToolsResult {
29            tools,
30            ..Default::default()
31        })
32    }
33
34    async fn call_tool(
35        &self,
36        request: CallToolRequestParams,
37        _context: RequestContext<RoleServer>,
38    ) -> Result<CallToolResult, ErrorData> {
39        let name = request.name.as_ref();
40        let args = request
41            .arguments
42            .as_ref()
43            .map(|m| serde_json::Value::Object(m.clone()))
44            .unwrap_or(json!({}));
45
46        match self.execute_tool(name, args).await {
47            Ok(value) => {
48                let text = match &value {
49                    serde_json::Value::String(s) => s.clone(),
50                    _ => serde_json::to_string_pretty(&value).unwrap_or_default(),
51                };
52                Ok(CallToolResult::success(vec![Content::text(text)]))
53            }
54            Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
55        }
56    }
57
58    fn get_tool(&self, name: &str) -> Option<Tool> {
59        build_tool_definitions()
60            .into_iter()
61            .find(|t| t.name.as_ref() == name)
62    }
63}
64
65fn build_tool_definitions() -> Vec<Tool> {
66    vec![
67        tool_def(
68            "eval_js",
69            "Execute JavaScript in the active page and return the result",
70            json!({
71                "type": "object",
72                "properties": {
73                    "code": { "type": "string", "description": "JavaScript code to execute" },
74                    "tab_id": { "type": "integer", "description": "Target tab ID (optional, defaults to active)" }
75                },
76                "required": ["code"]
77            }),
78        ),
79        tool_def(
80            "dom_snapshot",
81            "Get accessible DOM tree with ref handles for interaction",
82            json!({
83                "type": "object",
84                "properties": {
85                    "format": { "type": "string", "enum": ["compact", "json"], "description": "Output format" },
86                    "tab_id": { "type": "integer" }
87                }
88            }),
89        ),
90        tool_def(
91            "find_elements",
92            "Search DOM elements by text, role, selector, or attribute",
93            json!({
94                "type": "object",
95                "properties": {
96                    "query": { "type": "object", "description": "Search query with text/role/selector/attribute fields" },
97                    "tab_id": { "type": "integer" }
98                }
99            }),
100        ),
101        tool_def(
102            "interact",
103            "Click, hover, focus, scroll, or select elements",
104            json!({
105                "type": "object",
106                "properties": {
107                    "action": { "type": "string", "enum": ["click", "double_click", "hover", "focus", "scroll", "scroll_into_view", "select"] },
108                    "ref_id": { "type": "string", "description": "Element ref handle" },
109                    "timeout_ms": { "type": "integer" },
110                    "tab_id": { "type": "integer" }
111                },
112                "required": ["action"]
113            }),
114        ),
115        tool_def(
116            "input",
117            "Fill, type text, or press keyboard keys",
118            json!({
119                "type": "object",
120                "properties": {
121                    "action": { "type": "string", "enum": ["fill", "type", "press_key", "clear"] },
122                    "ref_id": { "type": "string" },
123                    "value": { "type": "string", "description": "Value for fill" },
124                    "text": { "type": "string", "description": "Text for type" },
125                    "key": { "type": "string", "description": "Key for press_key" },
126                    "timeout_ms": { "type": "integer" },
127                    "tab_id": { "type": "integer" }
128                },
129                "required": ["action"]
130            }),
131        ),
132        tool_def(
133            "inspect",
134            "CSS inspection, visual debug overlays, accessibility audit, performance metrics",
135            json!({
136                "type": "object",
137                "properties": {
138                    "action": { "type": "string", "enum": ["styles", "bounds", "highlight", "clear_highlights", "accessibility", "performance"] },
139                    "ref_id": { "type": "string" },
140                    "ref_ids": { "type": "array", "items": { "type": "string" } },
141                    "properties": { "type": "array", "items": { "type": "string" } },
142                    "color": { "type": "string" },
143                    "label": { "type": "string" },
144                    "tab_id": { "type": "integer" }
145                },
146                "required": ["action"]
147            }),
148        ),
149        tool_def(
150            "css",
151            "Inject or remove custom CSS for debugging/prototyping",
152            json!({
153                "type": "object",
154                "properties": {
155                    "action": { "type": "string", "enum": ["inject", "remove"] },
156                    "css": { "type": "string", "description": "CSS to inject" },
157                    "tab_id": { "type": "integer" }
158                },
159                "required": ["action"]
160            }),
161        ),
162        tool_def(
163            "logs",
164            "Console, network, navigation, dialog, and event logs",
165            json!({
166                "type": "object",
167                "properties": {
168                    "action": { "type": "string", "enum": ["console", "network", "navigation", "dialogs", "events"] },
169                    "since": { "type": "number", "description": "Timestamp filter" },
170                    "filter": { "type": "string" },
171                    "limit": { "type": "integer" },
172                    "tab_id": { "type": "integer" }
173                },
174                "required": ["action"]
175            }),
176        ),
177        tool_def(
178            "storage",
179            "localStorage, sessionStorage, and cookie access",
180            json!({
181                "type": "object",
182                "properties": {
183                    "action": { "type": "string", "enum": ["get", "set", "delete", "cookies"] },
184                    "store": { "type": "string", "enum": ["local", "session"] },
185                    "key": { "type": "string" },
186                    "value": { "type": "string" },
187                    "tab_id": { "type": "integer" }
188                },
189                "required": ["action"]
190            }),
191        ),
192        tool_def(
193            "navigate",
194            "Navigate pages, go back, manage dialogs",
195            json!({
196                "type": "object",
197                "properties": {
198                    "action": { "type": "string", "enum": ["go_to", "back", "history", "dialogs"] },
199                    "url": { "type": "string" },
200                    "tab_id": { "type": "integer" }
201                },
202                "required": ["action"]
203            }),
204        ),
205        tool_def(
206            "wait_for",
207            "Wait for DOM conditions, text, or URL changes",
208            json!({
209                "type": "object",
210                "properties": {
211                    "condition": { "type": "string", "enum": ["selector", "selector_gone", "text", "text_gone", "url"] },
212                    "value": { "type": "string", "description": "Selector, text, or URL pattern to wait for" },
213                    "timeout_ms": { "type": "integer", "description": "Max wait time (default 10000)" },
214                    "tab_id": { "type": "integer" }
215                },
216                "required": ["condition", "value"]
217            }),
218        ),
219        tool_def(
220            "assert_semantic",
221            "Evaluate an expression and assert a condition on the result",
222            json!({
223                "type": "object",
224                "properties": {
225                    "expression": { "type": "string", "description": "JavaScript expression to evaluate" },
226                    "condition": { "type": "string", "enum": ["equals", "not_equals", "contains", "truthy", "greater_than", "less_than"] },
227                    "expected": { "type": "string", "description": "Expected value for comparison" },
228                    "tab_id": { "type": "integer" }
229                },
230                "required": ["expression", "condition"]
231            }),
232        ),
233        tool_def(
234            "recording",
235            "Record interactions, create checkpoints, replay",
236            json!({
237                "type": "object",
238                "properties": {
239                    "action": { "type": "string", "enum": ["start", "stop", "checkpoint", "get_events", "list_checkpoints", "export"] },
240                    "label": { "type": "string", "description": "Checkpoint label" },
241                    "since": { "type": "number" },
242                    "tab_id": { "type": "integer" }
243                },
244                "required": ["action"]
245            }),
246        ),
247        tool_def(
248            "screenshot",
249            "Capture page screenshot as PNG (base64)",
250            json!({
251                "type": "object",
252                "properties": {
253                    "full_page": { "type": "boolean", "description": "Capture full scrollable page" },
254                    "tab_id": { "type": "integer" }
255                }
256            }),
257        ),
258        tool_def(
259            "tabs",
260            "List and manage browser tabs",
261            json!({
262                "type": "object",
263                "properties": {
264                    "action": { "type": "string", "enum": ["list"], "description": "Tab action" }
265                }
266            }),
267        ),
268        tool_def(
269            "page_info",
270            "Get page metadata, URL, title, and resource info",
271            json!({
272                "type": "object",
273                "properties": {
274                    "tab_id": { "type": "integer" }
275                }
276            }),
277        ),
278        tool_def(
279            "cookies",
280            "Get cookies for the current page",
281            json!({
282                "type": "object",
283                "properties": {
284                    "tab_id": { "type": "integer" }
285                }
286            }),
287        ),
288        tool_def(
289            "get_diagnostics",
290            "Browser extension diagnostics and health info",
291            json!({
292                "type": "object",
293                "properties": {
294                    "tab_id": { "type": "integer" }
295                }
296            }),
297        ),
298        tool_def(
299            "get_plugin_info",
300            "Extension and native host version info",
301            json!({
302                "type": "object",
303                "properties": {}
304            }),
305        ),
306        tool_def(
307            "get_memory_stats",
308            "JavaScript heap memory statistics",
309            json!({
310                "type": "object",
311                "properties": {
312                    "tab_id": { "type": "integer" }
313                }
314            }),
315        ),
316    ]
317}
318
319fn tool_def(name: &str, description: &str, schema: serde_json::Value) -> Tool {
320    serde_json::from_value(json!({
321        "name": name,
322        "description": description,
323        "inputSchema": schema,
324    }))
325    .expect("tool definition must be valid")
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::bridge_dispatch::BridgeDispatch;
332    use crate::tab_state::TabManager;
333    use rmcp::ServerHandler;
334    use std::sync::Arc;
335
336    fn make_handler() -> VictauriBrowserHandler {
337        let tab_mgr = Arc::new(TabManager::new());
338        let dispatch = Arc::new(BridgeDispatch::new(tokio::io::stdout()));
339        VictauriBrowserHandler::new(tab_mgr, dispatch)
340    }
341
342    #[test]
343    fn server_info_has_tools_capability() {
344        let handler = make_handler();
345        let info = handler.get_info();
346        let caps = info.capabilities;
347        assert!(caps.tools.is_some());
348    }
349
350    #[test]
351    fn tool_definitions_are_20() {
352        let tools = build_tool_definitions();
353        assert_eq!(tools.len(), 20);
354    }
355
356    #[test]
357    fn all_tools_have_descriptions() {
358        let tools = build_tool_definitions();
359        for tool in &tools {
360            assert!(
361                tool.description.is_some(),
362                "tool {} missing description",
363                tool.name
364            );
365        }
366    }
367
368    #[test]
369    fn tool_names_are_unique() {
370        let tools = build_tool_definitions();
371        let mut names: Vec<_> = tools.iter().map(|t| t.name.as_ref()).collect();
372        names.sort();
373        names.dedup();
374        assert_eq!(names.len(), 20);
375    }
376
377    #[test]
378    fn get_tool_finds_existing() {
379        let handler = make_handler();
380        let tool = handler.get_tool("eval_js");
381        assert!(tool.is_some());
382        assert_eq!(tool.unwrap().name.as_ref(), "eval_js");
383    }
384
385    #[test]
386    fn get_tool_returns_none_for_unknown() {
387        let handler = make_handler();
388        assert!(handler.get_tool("nonexistent").is_none());
389    }
390
391    #[test]
392    fn all_tools_have_input_schema() {
393        let tools = build_tool_definitions();
394        for tool in &tools {
395            assert!(
396                !tool.input_schema.is_empty(),
397                "tool {} has empty input schema",
398                tool.name
399            );
400        }
401    }
402
403    #[test]
404    fn tools_with_required_action_param() {
405        let action_tools = [
406            "interact",
407            "input",
408            "inspect",
409            "css",
410            "logs",
411            "storage",
412            "navigate",
413            "recording",
414        ];
415        let tools = build_tool_definitions();
416        for name in action_tools {
417            let tool = tools.iter().find(|t| t.name.as_ref() == name).unwrap();
418            let schema_value = serde_json::Value::Object((*tool.input_schema).clone());
419            let required = schema_value.get("required").and_then(|r| r.as_array());
420            assert!(
421                required.is_some_and(|r| r.iter().any(|v| v == "action")),
422                "tool {name} should require 'action' parameter"
423            );
424        }
425    }
426
427    #[test]
428    fn eval_js_requires_code_in_schema() {
429        let tools = build_tool_definitions();
430        let eval = tools.iter().find(|t| t.name.as_ref() == "eval_js").unwrap();
431        let schema_value = serde_json::Value::Object((*eval.input_schema).clone());
432        let required = schema_value.get("required").unwrap().as_array().unwrap();
433        assert!(required.iter().any(|v| v == "code"));
434    }
435
436    #[test]
437    fn assert_semantic_schema_has_conditions() {
438        let tools = build_tool_definitions();
439        let tool = tools
440            .iter()
441            .find(|t| t.name.as_ref() == "assert_semantic")
442            .unwrap();
443        let schema_value = serde_json::Value::Object((*tool.input_schema).clone());
444        let condition_enum = schema_value["properties"]["condition"]["enum"]
445            .as_array()
446            .unwrap();
447        let conditions: Vec<&str> = condition_enum.iter().map(|v| v.as_str().unwrap()).collect();
448        assert!(conditions.contains(&"equals"));
449        assert!(conditions.contains(&"truthy"));
450        assert!(conditions.contains(&"greater_than"));
451        assert!(conditions.contains(&"less_than"));
452        assert!(conditions.contains(&"contains"));
453        assert!(conditions.contains(&"not_equals"));
454    }
455
456    #[test]
457    fn get_tool_matches_list_tools() {
458        let handler = make_handler();
459        let tools = build_tool_definitions();
460        for tool in &tools {
461            let found = handler.get_tool(tool.name.as_ref());
462            assert!(found.is_some(), "get_tool should find {}", tool.name);
463            assert_eq!(found.unwrap().name, tool.name);
464        }
465    }
466
467    #[test]
468    fn server_instructions_mention_all_tools() {
469        let tools = build_tool_definitions();
470        for tool in &tools {
471            assert!(
472                SERVER_INSTRUCTIONS.contains(tool.name.as_ref()),
473                "instructions should mention {}",
474                tool.name
475            );
476        }
477    }
478
479    // --- Adversarial schema validation tests ---
480
481    #[test]
482    fn all_schemas_have_type_object() {
483        let tools = build_tool_definitions();
484        for tool in &tools {
485            let schema = serde_json::Value::Object((*tool.input_schema).clone());
486            assert_eq!(
487                schema["type"], "object",
488                "tool {} schema must be type:object",
489                tool.name
490            );
491        }
492    }
493
494    #[test]
495    fn all_schemas_have_properties() {
496        let tools = build_tool_definitions();
497        for tool in &tools {
498            let schema = serde_json::Value::Object((*tool.input_schema).clone());
499            assert!(
500                schema.get("properties").is_some(),
501                "tool {} schema must have 'properties'",
502                tool.name
503            );
504        }
505    }
506
507    #[test]
508    fn tool_names_match_handler_list() {
509        let handler = make_handler();
510        let handler_tools = handler.list_tools();
511        let mcp_tools = build_tool_definitions();
512
513        let mut handler_names: Vec<&str> = handler_tools.iter().map(|t| t.name.as_str()).collect();
514        let mut mcp_names: Vec<&str> = mcp_tools.iter().map(|t| t.name.as_ref()).collect();
515        handler_names.sort();
516        mcp_names.sort();
517
518        assert_eq!(
519            handler_names, mcp_names,
520            "handler tools must match MCP definitions"
521        );
522    }
523
524    #[test]
525    fn tab_id_present_in_most_schemas() {
526        let tools = build_tool_definitions();
527        let no_tab_tools = ["get_plugin_info", "tabs"];
528        for tool in &tools {
529            let name = tool.name.as_ref();
530            if no_tab_tools.contains(&name) {
531                continue;
532            }
533            let schema = serde_json::Value::Object((*tool.input_schema).clone());
534            assert!(
535                schema["properties"].get("tab_id").is_some(),
536                "tool {name} should have tab_id property"
537            );
538        }
539    }
540
541    #[test]
542    fn action_enum_values_match_handler_routing() {
543        let tools = build_tool_definitions();
544
545        let expected_actions: std::collections::HashMap<&str, Vec<&str>> = [
546            (
547                "interact",
548                vec![
549                    "click",
550                    "double_click",
551                    "hover",
552                    "focus",
553                    "scroll",
554                    "scroll_into_view",
555                    "select",
556                ],
557            ),
558            ("input", vec!["fill", "type", "press_key", "clear"]),
559            (
560                "inspect",
561                vec![
562                    "styles",
563                    "bounds",
564                    "highlight",
565                    "clear_highlights",
566                    "accessibility",
567                    "performance",
568                ],
569            ),
570            ("css", vec!["inject", "remove"]),
571            (
572                "logs",
573                vec!["console", "network", "navigation", "dialogs", "events"],
574            ),
575            ("storage", vec!["get", "set", "delete", "cookies"]),
576            ("navigate", vec!["go_to", "back", "history", "dialogs"]),
577            (
578                "recording",
579                vec![
580                    "start",
581                    "stop",
582                    "checkpoint",
583                    "get_events",
584                    "list_checkpoints",
585                    "export",
586                ],
587            ),
588        ]
589        .into_iter()
590        .collect();
591
592        for (tool_name, expected) in &expected_actions {
593            let tool = tools
594                .iter()
595                .find(|t| t.name.as_ref() == *tool_name)
596                .unwrap();
597            let schema = serde_json::Value::Object((*tool.input_schema).clone());
598            let enum_vals = schema["properties"]["action"]["enum"]
599                .as_array()
600                .unwrap_or_else(|| panic!("{tool_name} missing action enum"));
601            let mut actual: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect();
602            let mut expected_sorted = expected.clone();
603            actual.sort();
604            expected_sorted.sort();
605            assert_eq!(
606                actual, expected_sorted,
607                "action enum mismatch for {tool_name}"
608            );
609        }
610    }
611
612    #[test]
613    fn wait_for_requires_condition_and_value() {
614        let tools = build_tool_definitions();
615        let tool = tools
616            .iter()
617            .find(|t| t.name.as_ref() == "wait_for")
618            .unwrap();
619        let schema = serde_json::Value::Object((*tool.input_schema).clone());
620        let required = schema["required"].as_array().unwrap();
621        let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
622        assert!(required_names.contains(&"condition"));
623        assert!(required_names.contains(&"value"));
624    }
625
626    #[test]
627    fn assert_semantic_requires_expression_and_condition() {
628        let tools = build_tool_definitions();
629        let tool = tools
630            .iter()
631            .find(|t| t.name.as_ref() == "assert_semantic")
632            .unwrap();
633        let schema = serde_json::Value::Object((*tool.input_schema).clone());
634        let required = schema["required"].as_array().unwrap();
635        let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
636        assert!(required_names.contains(&"expression"));
637        assert!(required_names.contains(&"condition"));
638    }
639
640    #[test]
641    fn no_tool_has_empty_description() {
642        let tools = build_tool_definitions();
643        for tool in &tools {
644            let desc = tool.description.as_deref().unwrap_or("");
645            assert!(
646                desc.len() > 10,
647                "tool {} has too-short description: {:?}",
648                tool.name,
649                desc
650            );
651        }
652    }
653
654    #[test]
655    fn tool_def_json_roundtrips() {
656        let tools = build_tool_definitions();
657        for tool in &tools {
658            let serialized = serde_json::to_string(&tool).unwrap();
659            let deserialized: serde_json::Value = serde_json::from_str(&serialized).unwrap();
660            assert_eq!(
661                deserialized["name"].as_str().unwrap(),
662                tool.name.as_ref(),
663                "tool {} failed JSON roundtrip",
664                tool.name
665            );
666        }
667    }
668
669    // --- Deep challenger: cross-module consistency ---
670
671    #[tokio::test]
672    async fn schema_tool_names_match_handler_tool_list() {
673        use crate::bridge_dispatch::BridgeDispatch;
674        use crate::mcp_handler::VictauriBrowserHandler;
675        use crate::tab_state::TabManager;
676        use std::sync::Arc;
677
678        let tab_mgr = Arc::new(TabManager::new());
679        let dispatch = Arc::new(BridgeDispatch::new(tokio::io::stdout()));
680        let handler = VictauriBrowserHandler::new(tab_mgr, dispatch);
681
682        let schema_tools = build_tool_definitions();
683        let handler_tools = handler.list_tools();
684
685        let schema_names: std::collections::HashSet<&str> =
686            schema_tools.iter().map(|t| t.name.as_ref()).collect();
687        let handler_names: std::collections::HashSet<&str> =
688            handler_tools.iter().map(|t| t.name.as_str()).collect();
689
690        // Every schema tool must be in the handler list
691        for name in &schema_names {
692            assert!(
693                handler_names.contains(name),
694                "schema defines '{name}' but handler.list_tools() doesn't"
695            );
696        }
697
698        // Every handler tool must be in the schema
699        for name in &handler_names {
700            assert!(
701                schema_names.contains(name),
702                "handler lists '{name}' but schema doesn't define it"
703            );
704        }
705
706        assert_eq!(schema_names.len(), handler_names.len());
707    }
708
709    #[tokio::test]
710    async fn all_schema_tools_recognized_by_handler() {
711        use crate::bridge_dispatch::BridgeDispatch;
712        use crate::mcp_handler::VictauriBrowserHandler;
713        use crate::tab_state::TabManager;
714        use std::sync::Arc;
715
716        let tab_mgr = Arc::new(TabManager::new());
717        let dispatch = Arc::new(BridgeDispatch::new(tokio::io::stdout()));
718        let handler = VictauriBrowserHandler::new(Arc::clone(&tab_mgr), Arc::clone(&dispatch));
719
720        // Spawn a responder to resolve any bridge commands immediately
721        let d = Arc::clone(&dispatch);
722        let responder = tokio::spawn(async move {
723            for _ in 0..200 {
724                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
725                let ids = d.pending_ids().await;
726                for id in ids {
727                    d.on_response(&id, Some(json!({"mock": true, "js_heap": {}})), None)
728                        .await;
729                }
730            }
731        });
732
733        let tools = build_tool_definitions();
734        for tool in &tools {
735            let name = tool.name.as_ref();
736            let result = handler.execute_tool(name, json!({})).await;
737            match result {
738                Ok(_) => {}
739                Err(e) => {
740                    assert!(
741                        !e.contains("unknown tool"),
742                        "schema tool '{name}' not recognized by handler: {e}"
743                    );
744                }
745            }
746        }
747
748        responder.abort();
749    }
750
751    #[test]
752    fn server_instructions_list_all_tool_names() {
753        let tools = build_tool_definitions();
754        for tool in &tools {
755            let name = tool.name.as_ref();
756            assert!(
757                SERVER_INSTRUCTIONS.contains(name),
758                "tool '{name}' missing from SERVER_INSTRUCTIONS string"
759            );
760        }
761    }
762
763    #[test]
764    fn no_duplicate_tool_names_in_schema() {
765        let tools = build_tool_definitions();
766        let mut seen = std::collections::HashSet::new();
767        for tool in &tools {
768            assert!(
769                seen.insert(tool.name.as_ref()),
770                "duplicate tool name in schema: {}",
771                tool.name
772            );
773        }
774    }
775
776    #[test]
777    fn schema_required_fields_are_in_properties() {
778        let tools = build_tool_definitions();
779        for tool in &tools {
780            let schema = serde_json::Value::Object((*tool.input_schema).clone());
781            if let Some(required) = schema["required"].as_array() {
782                let properties = schema["properties"]
783                    .as_object()
784                    .unwrap_or_else(|| panic!("{} has required but no properties", tool.name));
785                for req in required {
786                    let field = req.as_str().unwrap();
787                    assert!(
788                        properties.contains_key(field),
789                        "{}: required field '{}' not in properties",
790                        tool.name,
791                        field
792                    );
793                }
794            }
795        }
796    }
797}