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 #[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 #[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 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 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 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}