1mod backend_params;
2mod compound_params;
3mod helpers;
4mod other_params;
5mod server;
6mod verification_params;
7mod webview_params;
8mod window_params;
9
10use std::collections::HashSet;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use rmcp::handler::server::tool::ToolCallContext;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::model::{
17 AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
18 ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
19 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
20 Tool, UnsubscribeRequestParams,
21};
22use rmcp::service::RequestContext;
23use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
24use tokio::sync::Mutex;
25
26use crate::VictauriState;
27use crate::bridge::WebviewBridge;
28
29use helpers::{
30 js_string, json_result, missing_param, sanitize_css_color, tool_disabled, tool_error,
31 validate_url,
32};
33
34pub use backend_params::*;
35pub use compound_params::*;
36pub use other_params::{
37 FindElementsParams, ResolveCommandParams, SemanticAssertParams, WaitCondition, WaitForParams,
38};
39pub use server::*;
40pub use verification_params::*;
41pub use webview_params::*;
42pub use window_params::*;
43
44pub(crate) const MAX_PENDING_EVALS: usize = 100;
49
50const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
51const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
52const RESOURCE_URI_STATE: &str = "victauri://state";
53
54const BRIDGE_VERSION: &str = "0.3.0";
55
56#[derive(Clone)]
58pub struct VictauriMcpHandler {
59 state: Arc<VictauriState>,
60 bridge: Arc<dyn WebviewBridge>,
61 subscriptions: Arc<Mutex<HashSet<String>>>,
62 bridge_checked: Arc<AtomicBool>,
63}
64
65#[tool_router]
66impl VictauriMcpHandler {
67 #[tool(
70 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
71 annotations(
72 read_only_hint = false,
73 destructive_hint = true,
74 idempotent_hint = false,
75 open_world_hint = false
76 )
77 )]
78 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
79 if !self.state.privacy.is_tool_enabled("eval_js") {
80 return tool_disabled("eval_js");
81 }
82 match self
83 .eval_with_return(¶ms.code, params.webview_label.as_deref())
84 .await
85 {
86 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
87 Err(e) => tool_error(e),
88 }
89 }
90
91 #[tool(
92 description = "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).",
93 annotations(
94 read_only_hint = true,
95 destructive_hint = false,
96 idempotent_hint = true,
97 open_world_hint = false
98 )
99 )]
100 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
101 let format = params.format.unwrap_or(SnapshotFormat::Compact);
102 let format_str = match format {
103 SnapshotFormat::Compact => "compact",
104 SnapshotFormat::Json => "json",
105 };
106 let code = format!(
107 "return window.__VICTAURI__?.snapshot({})",
108 js_string(format_str)
109 );
110 self.eval_bridge(&code, params.webview_label.as_deref())
111 .await
112 }
113
114 #[tool(
115 description = "Search for elements by text, role, test_id, CSS selector, or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
116 annotations(
117 read_only_hint = true,
118 destructive_hint = false,
119 idempotent_hint = true,
120 open_world_hint = false
121 )
122 )]
123 async fn find_elements(
124 &self,
125 Parameters(params): Parameters<FindElementsParams>,
126 ) -> CallToolResult {
127 let mut parts: Vec<String> = Vec::new();
128 if let Some(t) = ¶ms.text {
129 parts.push(format!("text: {}", js_string(t)));
130 }
131 if let Some(r) = ¶ms.role {
132 parts.push(format!("role: {}", js_string(r)));
133 }
134 if let Some(tid) = ¶ms.test_id {
135 parts.push(format!("test_id: {}", js_string(tid)));
136 }
137 if let Some(c) = ¶ms.css {
138 parts.push(format!("css: {}", js_string(c)));
139 }
140 if let Some(n) = ¶ms.name {
141 parts.push(format!("name: {}", js_string(n)));
142 }
143 if let Some(max) = params.max_results {
144 parts.push(format!("max_results: {max}"));
145 }
146 let code = format!(
147 "return window.__VICTAURI__?.findElements({{ {} }})",
148 parts.join(", ")
149 );
150 self.eval_bridge(&code, params.webview_label.as_deref())
151 .await
152 }
153
154 #[tool(
155 description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.",
156 annotations(
157 read_only_hint = false,
158 destructive_hint = true,
159 idempotent_hint = false,
160 open_world_hint = false
161 )
162 )]
163 async fn invoke_command(
164 &self,
165 Parameters(params): Parameters<InvokeCommandParams>,
166 ) -> CallToolResult {
167 if !self.state.privacy.is_tool_enabled("invoke_command") {
168 return tool_disabled("invoke_command");
169 }
170 if !self.state.privacy.is_command_allowed(¶ms.command) {
171 return tool_error(format!(
172 "command '{}' is blocked by privacy configuration",
173 params.command
174 ));
175 }
176 let args_json = params.args.unwrap_or(serde_json::json!({}));
177 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
178 let code = format!(
179 "return window.__TAURI__.core.invoke({}, {args_str})",
180 js_string(¶ms.command)
181 );
182 match self
183 .eval_with_return(&code, params.webview_label.as_deref())
184 .await
185 {
186 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
187 Err(e) => tool_error(format!("invoke_command failed: {e}")),
188 }
189 }
190
191 #[tool(
192 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
193 annotations(
194 read_only_hint = true,
195 destructive_hint = false,
196 idempotent_hint = true,
197 open_world_hint = false
198 )
199 )]
200 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
201 self.track_tool_call();
202 if !self.state.privacy.is_tool_enabled("screenshot") {
203 return tool_disabled("screenshot");
204 }
205 match self
206 .bridge
207 .get_native_handle(params.window_label.as_deref())
208 {
209 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
210 Ok(png_bytes) => {
211 use base64::Engine;
212 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
213 CallToolResult::success(vec![Content::image(b64, "image/png")])
214 }
215 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
216 },
217 Err(e) => tool_error(format!("cannot get window handle: {e}")),
218 }
219 }
220
221 #[tool(
222 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
223 annotations(
224 read_only_hint = true,
225 destructive_hint = false,
226 idempotent_hint = true,
227 open_world_hint = false
228 )
229 )]
230 async fn verify_state(
231 &self,
232 Parameters(params): Parameters<VerifyStateParams>,
233 ) -> CallToolResult {
234 let code = format!("return ({})", params.frontend_expr);
235 let frontend_json = match self
236 .eval_with_return(&code, params.webview_label.as_deref())
237 .await
238 {
239 Ok(result) => result,
240 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
241 };
242
243 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
244 Ok(v) => v,
245 Err(e) => {
246 return tool_error(format!(
247 "frontend expression did not return valid JSON: {e}"
248 ));
249 }
250 };
251
252 let result = victauri_core::verify_state(frontend_state, params.backend_state);
253 json_result(&result)
254 }
255
256 #[tool(
257 description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads from the JS-side IPC interception log.",
258 annotations(
259 read_only_hint = true,
260 destructive_hint = false,
261 idempotent_hint = true,
262 open_world_hint = false
263 )
264 )]
265 async fn detect_ghost_commands(
266 &self,
267 Parameters(params): Parameters<GhostCommandParams>,
268 ) -> CallToolResult {
269 let code = "return window.__VICTAURI__?.getIpcLog()";
270 let ipc_json = match self
271 .eval_with_return(code, params.webview_label.as_deref())
272 .await
273 {
274 Ok(r) => r,
275 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
276 };
277
278 let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
279 Ok(v) => v,
280 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
281 };
282 let frontend_commands: Vec<String> = ipc_calls
283 .iter()
284 .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
285 .collect::<std::collections::HashSet<_>>()
286 .into_iter()
287 .collect();
288
289 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
290 json_result(&report)
291 }
292
293 #[tool(
294 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
295 annotations(
296 read_only_hint = true,
297 destructive_hint = false,
298 idempotent_hint = true,
299 open_world_hint = false
300 )
301 )]
302 async fn check_ipc_integrity(
303 &self,
304 Parameters(params): Parameters<IpcIntegrityParams>,
305 ) -> CallToolResult {
306 let threshold = params.stale_threshold_ms.unwrap_or(5000);
307 let code = format!(
308 r"return (function() {{
309 var log = window.__VICTAURI__?.getIpcLog() || [];
310 var now = Date.now();
311 var threshold = {threshold};
312 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
313 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
314 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
315 return {{
316 healthy: stale.length === 0 && errored.length === 0,
317 total_calls: log.length,
318 pending_count: pending.length,
319 stale_count: stale.length,
320 error_count: errored.length,
321 stale_calls: stale.slice(0, 20),
322 errored_calls: errored.slice(0, 20)
323 }};
324 }})()"
325 );
326 self.eval_bridge(&code, params.webview_label.as_deref())
327 .await
328 }
329
330 #[tool(
331 description = "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests).",
332 annotations(
333 read_only_hint = true,
334 destructive_hint = false,
335 idempotent_hint = true,
336 open_world_hint = false
337 )
338 )]
339 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
340 let value = params
341 .value
342 .as_ref()
343 .map_or_else(|| "null".to_string(), |v| js_string(v));
344 let timeout_ms = params.timeout_ms.unwrap_or(10000);
345 let poll = params.poll_ms.unwrap_or(200);
346 let code = format!(
347 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
348 js_string(params.condition.as_str())
349 );
350 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
351 match self
352 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
353 .await
354 {
355 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
356 Err(e) => tool_error(e),
357 }
358 }
359
360 #[tool(
361 description = "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is.",
362 annotations(
363 read_only_hint = true,
364 destructive_hint = false,
365 idempotent_hint = true,
366 open_world_hint = false
367 )
368 )]
369 async fn assert_semantic(
370 &self,
371 Parameters(params): Parameters<SemanticAssertParams>,
372 ) -> CallToolResult {
373 let code = format!("return ({})", params.expression);
374 let actual_json = match self
375 .eval_with_return(&code, params.webview_label.as_deref())
376 .await
377 {
378 Ok(result) => result,
379 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
380 };
381
382 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
383 Ok(v) => v,
384 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
385 };
386
387 let assertion = victauri_core::SemanticAssertion {
388 label: params.label,
389 condition: params.condition,
390 expected: params.expected,
391 };
392
393 let result = victauri_core::evaluate_assertion(actual, &assertion);
394 json_result(&result)
395 }
396
397 #[tool(
398 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
399 annotations(
400 read_only_hint = true,
401 destructive_hint = false,
402 idempotent_hint = true,
403 open_world_hint = false
404 )
405 )]
406 async fn resolve_command(
407 &self,
408 Parameters(params): Parameters<ResolveCommandParams>,
409 ) -> CallToolResult {
410 self.track_tool_call();
411 let limit = params.limit.unwrap_or(5);
412 let mut results = self.state.registry.resolve(¶ms.query);
413 results.truncate(limit);
414 json_result(&results)
415 }
416
417 #[tool(
418 description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via #[inspectable] macro.",
419 annotations(
420 read_only_hint = true,
421 destructive_hint = false,
422 idempotent_hint = true,
423 open_world_hint = false
424 )
425 )]
426 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
427 self.track_tool_call();
428 let commands = match params.query {
429 Some(q) => self.state.registry.search(&q),
430 None => self.state.registry.list(),
431 };
432 json_result(&commands)
433 }
434
435 #[tool(
436 description = "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size.",
437 annotations(
438 read_only_hint = true,
439 destructive_hint = false,
440 idempotent_hint = true,
441 open_world_hint = false
442 )
443 )]
444 async fn get_memory_stats(&self) -> CallToolResult {
445 self.track_tool_call();
446 let stats = crate::memory::current_stats();
447 json_result(&stats)
448 }
449
450 #[tool(
451 description = "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting.",
452 annotations(
453 read_only_hint = true,
454 destructive_hint = false,
455 idempotent_hint = true,
456 open_world_hint = false
457 )
458 )]
459 async fn get_plugin_info(&self) -> CallToolResult {
460 self.track_tool_call();
461 let disabled: Vec<&str> = self
462 .state
463 .privacy
464 .disabled_tools
465 .iter()
466 .map(std::string::String::as_str)
467 .collect();
468 let blocklist: Vec<&str> = self
469 .state
470 .privacy
471 .command_blocklist
472 .iter()
473 .map(std::string::String::as_str)
474 .collect();
475 let allowlist: Option<Vec<&str>> = self
476 .state
477 .privacy
478 .command_allowlist
479 .as_ref()
480 .map(|s| s.iter().map(std::string::String::as_str).collect());
481 let all_tools = Self::tool_router().list_all();
482 let enabled_tools: Vec<&str> = all_tools
483 .iter()
484 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
485 .map(|t| t.name.as_ref())
486 .collect();
487
488 let result = serde_json::json!({
489 "version": env!("CARGO_PKG_VERSION"),
490 "bridge_version": BRIDGE_VERSION,
491 "port": self.state.port.load(Ordering::Relaxed),
492 "tools": {
493 "total": all_tools.len(),
494 "enabled": enabled_tools.len(),
495 "enabled_list": enabled_tools,
496 "disabled_list": disabled,
497 },
498 "commands": {
499 "allowlist": allowlist,
500 "blocklist": blocklist,
501 },
502 "privacy": {
503 "profile": self.state.privacy.profile.to_string(),
504 "redaction_enabled": self.state.privacy.redaction_enabled,
505 },
506 "capacities": {
507 "event_log": self.state.event_log.capacity(),
508 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
509 },
510 "registered_commands": self.state.registry.count(),
511 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
512 "uptime_secs": self.state.started_at.elapsed().as_secs(),
513 });
514 json_result(&result)
515 }
516
517 #[tool(
520 description = "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions.",
521 annotations(
522 read_only_hint = false,
523 destructive_hint = false,
524 idempotent_hint = false,
525 open_world_hint = false
526 )
527 )]
528 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
529 if !self.state.privacy.is_tool_enabled("interact") {
530 return tool_disabled("interact");
531 }
532 match params.action {
533 InteractAction::Click => {
534 let Some(ref_id) = ¶ms.ref_id else {
535 return missing_param("ref_id", "click");
536 };
537 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
538 self.eval_bridge(&code, params.webview_label.as_deref())
539 .await
540 }
541 InteractAction::DoubleClick => {
542 let Some(ref_id) = ¶ms.ref_id else {
543 return missing_param("ref_id", "double_click");
544 };
545 let code = format!(
546 "return window.__VICTAURI__?.doubleClick({})",
547 js_string(ref_id)
548 );
549 self.eval_bridge(&code, params.webview_label.as_deref())
550 .await
551 }
552 InteractAction::Hover => {
553 let Some(ref_id) = ¶ms.ref_id else {
554 return missing_param("ref_id", "hover");
555 };
556 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
557 self.eval_bridge(&code, params.webview_label.as_deref())
558 .await
559 }
560 InteractAction::Focus => {
561 let Some(ref_id) = ¶ms.ref_id else {
562 return missing_param("ref_id", "focus");
563 };
564 let code = format!(
565 "return window.__VICTAURI__?.focusElement({})",
566 js_string(ref_id)
567 );
568 self.eval_bridge(&code, params.webview_label.as_deref())
569 .await
570 }
571 InteractAction::ScrollIntoView => {
572 let ref_arg = params
573 .ref_id
574 .as_ref()
575 .map_or_else(|| "null".to_string(), |r| js_string(r));
576 let x = params.x.unwrap_or(0.0);
577 let y = params.y.unwrap_or(0.0);
578 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
579 self.eval_bridge(&code, params.webview_label.as_deref())
580 .await
581 }
582 InteractAction::SelectOption => {
583 let Some(ref_id) = ¶ms.ref_id else {
584 return missing_param("ref_id", "select_option");
585 };
586 let values = params.values.as_deref().unwrap_or(&[]);
587 let values_json =
588 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
589 let code = format!(
590 "return window.__VICTAURI__?.selectOption({}, {})",
591 js_string(ref_id),
592 values_json
593 );
594 self.eval_bridge(&code, params.webview_label.as_deref())
595 .await
596 }
597 }
598 }
599
600 #[tool(
601 description = "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls.",
602 annotations(
603 read_only_hint = false,
604 destructive_hint = false,
605 idempotent_hint = false,
606 open_world_hint = false
607 )
608 )]
609 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
610 match params.action {
611 InputAction::Fill => {
612 if !self.state.privacy.is_tool_enabled("fill") {
613 return tool_disabled("fill");
614 }
615 let Some(ref_id) = ¶ms.ref_id else {
616 return missing_param("ref_id", "fill");
617 };
618 let Some(value) = ¶ms.value else {
619 return missing_param("value", "fill");
620 };
621 let code = format!(
622 "return window.__VICTAURI__?.fill({}, {})",
623 js_string(ref_id),
624 js_string(value)
625 );
626 self.eval_bridge(&code, params.webview_label.as_deref())
627 .await
628 }
629 InputAction::TypeText => {
630 if !self.state.privacy.is_tool_enabled("type_text") {
631 return tool_disabled("type_text");
632 }
633 let Some(ref_id) = ¶ms.ref_id else {
634 return missing_param("ref_id", "type_text");
635 };
636 let Some(text) = ¶ms.text else {
637 return missing_param("text", "type_text");
638 };
639 let code = format!(
640 "return window.__VICTAURI__?.type({}, {})",
641 js_string(ref_id),
642 js_string(text)
643 );
644 self.eval_bridge(&code, params.webview_label.as_deref())
645 .await
646 }
647 InputAction::PressKey => {
648 let Some(key) = ¶ms.key else {
649 return missing_param("key", "press_key");
650 };
651 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
652 self.eval_bridge(&code, params.webview_label.as_deref())
653 .await
654 }
655 }
656 }
657
658 #[tool(
659 description = "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title.",
660 annotations(
661 read_only_hint = false,
662 destructive_hint = false,
663 idempotent_hint = true,
664 open_world_hint = false
665 )
666 )]
667 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
668 self.track_tool_call();
669 match params.action {
670 WindowAction::GetState => {
671 let states = self.bridge.get_window_states(params.label.as_deref());
672 json_result(&states)
673 }
674 WindowAction::List => {
675 let labels = self.bridge.list_window_labels();
676 json_result(&labels)
677 }
678 WindowAction::Manage => {
679 if !self.state.privacy.is_tool_enabled("window.manage") {
680 return tool_disabled("window.manage");
681 }
682 let Some(manage_action) = ¶ms.manage_action else {
683 return missing_param("manage_action", "manage");
684 };
685 match self
686 .bridge
687 .manage_window(params.label.as_deref(), manage_action.as_str())
688 {
689 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
690 Err(e) => tool_error(e),
691 }
692 }
693 WindowAction::Resize => {
694 if !self.state.privacy.is_tool_enabled("window.resize") {
695 return tool_disabled("window.resize");
696 }
697 let Some(width) = params.width else {
698 return missing_param("width", "resize");
699 };
700 let Some(height) = params.height else {
701 return missing_param("height", "resize");
702 };
703 match self
704 .bridge
705 .resize_window(params.label.as_deref(), width, height)
706 {
707 Ok(()) => {
708 let result =
709 serde_json::json!({"ok": true, "width": width, "height": height});
710 CallToolResult::success(vec![Content::text(result.to_string())])
711 }
712 Err(e) => tool_error(e),
713 }
714 }
715 WindowAction::MoveTo => {
716 if !self.state.privacy.is_tool_enabled("window.move_to") {
717 return tool_disabled("window.move_to");
718 }
719 let Some(x) = params.x else {
720 return missing_param("x", "move_to");
721 };
722 let Some(y) = params.y else {
723 return missing_param("y", "move_to");
724 };
725 match self.bridge.move_window(params.label.as_deref(), x, y) {
726 Ok(()) => {
727 let result = serde_json::json!({"ok": true, "x": x, "y": y});
728 CallToolResult::success(vec![Content::text(result.to_string())])
729 }
730 Err(e) => tool_error(e),
731 }
732 }
733 WindowAction::SetTitle => {
734 if !self.state.privacy.is_tool_enabled("window.set_title") {
735 return tool_disabled("window.set_title");
736 }
737 let Some(title) = ¶ms.title else {
738 return missing_param("title", "set_title");
739 };
740 match self.bridge.set_window_title(params.label.as_deref(), title) {
741 Ok(()) => {
742 let result = serde_json::json!({"ok": true, "title": title});
743 CallToolResult::success(vec![Content::text(result.to_string())])
744 }
745 Err(e) => tool_error(e),
746 }
747 }
748 }
749 }
750
751 #[tool(
752 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
753 annotations(
754 read_only_hint = false,
755 destructive_hint = true,
756 idempotent_hint = false,
757 open_world_hint = false
758 )
759 )]
760 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
761 match params.action {
762 StorageAction::Get => {
763 let method = match params.storage_type.unwrap_or(StorageType::Local) {
764 StorageType::Session => "getSessionStorage",
765 StorageType::Local => "getLocalStorage",
766 };
767 let key_arg = params
768 .key
769 .as_ref()
770 .map(|k| js_string(k))
771 .unwrap_or_default();
772 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
773 self.eval_bridge(&code, params.webview_label.as_deref())
774 .await
775 }
776 StorageAction::Set => {
777 if !self.state.privacy.is_tool_enabled("set_storage") {
778 return tool_disabled("set_storage");
779 }
780 let method = match params.storage_type.unwrap_or(StorageType::Local) {
781 StorageType::Session => "setSessionStorage",
782 StorageType::Local => "setLocalStorage",
783 };
784 let Some(key) = ¶ms.key else {
785 return missing_param("key", "set");
786 };
787 let value = params
788 .value
789 .as_ref()
790 .cloned()
791 .unwrap_or(serde_json::Value::Null);
792 let value_json =
793 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
794 let code = format!(
795 "return window.__VICTAURI__?.{method}({}, {value_json})",
796 js_string(key)
797 );
798 self.eval_bridge(&code, params.webview_label.as_deref())
799 .await
800 }
801 StorageAction::Delete => {
802 if !self.state.privacy.is_tool_enabled("delete_storage") {
803 return tool_disabled("delete_storage");
804 }
805 let method = match params.storage_type.unwrap_or(StorageType::Local) {
806 StorageType::Session => "deleteSessionStorage",
807 StorageType::Local => "deleteLocalStorage",
808 };
809 let Some(key) = ¶ms.key else {
810 return missing_param("key", "delete");
811 };
812 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
813 self.eval_bridge(&code, params.webview_label.as_deref())
814 .await
815 }
816 StorageAction::GetCookies => {
817 self.eval_bridge(
818 "return window.__VICTAURI__?.getCookies()",
819 params.webview_label.as_deref(),
820 )
821 .await
822 }
823 }
824 }
825
826 #[tool(
827 description = "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response.",
828 annotations(
829 read_only_hint = false,
830 destructive_hint = false,
831 idempotent_hint = false,
832 open_world_hint = false
833 )
834 )]
835 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
836 match params.action {
837 NavigateAction::GoTo => {
838 if !self.state.privacy.is_tool_enabled("navigate") {
839 return tool_disabled("navigate");
840 }
841 let Some(url) = ¶ms.url else {
842 return missing_param("url", "go_to");
843 };
844 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
845 return tool_error(e);
846 }
847 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
848 self.eval_bridge(&code, params.webview_label.as_deref())
849 .await
850 }
851 NavigateAction::GoBack => {
852 self.eval_bridge(
853 "return window.__VICTAURI__?.navigateBack()",
854 params.webview_label.as_deref(),
855 )
856 .await
857 }
858 NavigateAction::GetHistory => {
859 self.eval_bridge(
860 "return window.__VICTAURI__?.getNavigationLog()",
861 params.webview_label.as_deref(),
862 )
863 .await
864 }
865 NavigateAction::SetDialogResponse => {
866 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
867 return tool_disabled("set_dialog_response");
868 }
869 let Some(dialog_type) = params.dialog_type else {
870 return missing_param("dialog_type", "set_dialog_response");
871 };
872 let Some(dialog_action) = params.dialog_action else {
873 return missing_param("dialog_action", "set_dialog_response");
874 };
875 let text_arg = params
876 .text
877 .as_ref()
878 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
879 let code = format!(
880 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
881 js_string(dialog_type.as_str()),
882 js_string(dialog_action.as_str())
883 );
884 self.eval_bridge(&code, params.webview_label.as_deref())
885 .await
886 }
887 NavigateAction::GetDialogLog => {
888 self.eval_bridge(
889 "return window.__VICTAURI__?.getDialogLog()",
890 params.webview_label.as_deref(),
891 )
892 .await
893 }
894 }
895 }
896
897 #[tool(
898 description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON).",
899 annotations(
900 read_only_hint = false,
901 destructive_hint = false,
902 idempotent_hint = false,
903 open_world_hint = false
904 )
905 )]
906 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
907 self.track_tool_call();
908 if !self.state.privacy.is_tool_enabled("recording") {
909 return tool_disabled("recording");
910 }
911 match params.action {
912 RecordingAction::Start => {
913 let session_id = params
914 .session_id
915 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
916 match self.state.recorder.start(session_id.clone()) {
917 Ok(()) => {
918 let result = serde_json::json!({
919 "started": true,
920 "session_id": session_id,
921 });
922 CallToolResult::success(vec![Content::text(result.to_string())])
923 }
924 Err(e) => tool_error(e.to_string()),
925 }
926 }
927 RecordingAction::Stop => match self.state.recorder.stop() {
928 Some(session) => json_result(&session),
929 None => tool_error("no recording is active"),
930 },
931 RecordingAction::Checkpoint => {
932 let Some(id) = params.checkpoint_id else {
933 return missing_param("checkpoint_id", "checkpoint");
934 };
935 let state = params.state.unwrap_or(serde_json::Value::Null);
936 match self
937 .state
938 .recorder
939 .checkpoint(id.clone(), params.checkpoint_label, state)
940 {
941 Ok(()) => {
942 let result = serde_json::json!({
943 "created": true,
944 "checkpoint_id": id,
945 "event_index": self.state.recorder.event_count(),
946 });
947 CallToolResult::success(vec![Content::text(result.to_string())])
948 }
949 Err(e) => tool_error(e.to_string()),
950 }
951 }
952 RecordingAction::ListCheckpoints => {
953 let checkpoints = self.state.recorder.get_checkpoints();
954 json_result(&checkpoints)
955 }
956 RecordingAction::GetEvents => {
957 let events = self
958 .state
959 .recorder
960 .events_since(params.since_index.unwrap_or(0));
961 json_result(&events)
962 }
963 RecordingAction::EventsBetween => {
964 let Some(from) = ¶ms.from else {
965 return missing_param("from", "events_between");
966 };
967 let Some(to) = ¶ms.to else {
968 return missing_param("to", "events_between");
969 };
970 match self.state.recorder.events_between_checkpoints(from, to) {
971 Ok(events) => json_result(&events),
972 Err(e) => tool_error(e.to_string()),
973 }
974 }
975 RecordingAction::GetReplay => {
976 let calls = self.state.recorder.ipc_replay_sequence();
977 json_result(&calls)
978 }
979 RecordingAction::Export => match self.state.recorder.export() {
980 Some(s) => {
981 let json = serde_json::to_string_pretty(&s)
982 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
983 CallToolResult::success(vec![Content::text(json)])
984 }
985 None => tool_error("no recording is active — start one first"),
986 },
987 RecordingAction::Import => {
988 let Some(session_json) = ¶ms.session_json else {
989 return missing_param("session_json", "import");
990 };
991 let session: victauri_core::RecordedSession =
992 match serde_json::from_str(session_json) {
993 Ok(s) => s,
994 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
995 };
996
997 let result = serde_json::json!({
998 "imported": true,
999 "session_id": session.id,
1000 "event_count": session.events.len(),
1001 "checkpoint_count": session.checkpoints.len(),
1002 "started_at": session.started_at.to_rfc3339(),
1003 });
1004 self.state.recorder.import(session);
1005 CallToolResult::success(vec![Content::text(result.to_string())])
1006 }
1007 }
1008 }
1009
1010 #[tool(
1011 description = "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics).",
1012 annotations(
1013 read_only_hint = true,
1014 destructive_hint = false,
1015 idempotent_hint = true,
1016 open_world_hint = false
1017 )
1018 )]
1019 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1020 match params.action {
1021 InspectAction::GetStyles => {
1022 let Some(ref_id) = ¶ms.ref_id else {
1023 return missing_param("ref_id", "get_styles");
1024 };
1025 let props_arg = match ¶ms.properties {
1026 Some(props) => {
1027 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1028 format!("[{}]", arr.join(","))
1029 }
1030 None => "null".to_string(),
1031 };
1032 let code = format!(
1033 "return window.__VICTAURI__?.getStyles({}, {})",
1034 js_string(ref_id),
1035 props_arg
1036 );
1037 self.eval_bridge(&code, params.webview_label.as_deref())
1038 .await
1039 }
1040 InspectAction::GetBoundingBoxes => {
1041 let Some(ref_ids) = ¶ms.ref_ids else {
1042 return missing_param("ref_ids", "get_bounding_boxes");
1043 };
1044 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1045 let code = format!(
1046 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1047 refs.join(",")
1048 );
1049 self.eval_bridge(&code, params.webview_label.as_deref())
1050 .await
1051 }
1052 InspectAction::Highlight => {
1053 let Some(ref_id) = ¶ms.ref_id else {
1054 return missing_param("ref_id", "highlight");
1055 };
1056 let color_arg = match ¶ms.color {
1057 Some(c) => match sanitize_css_color(c) {
1058 Ok(safe) => format!("\"{safe}\""),
1059 Err(e) => return tool_error(e),
1060 },
1061 None => "null".to_string(),
1062 };
1063 let label_arg = match ¶ms.label {
1064 Some(l) => js_string(l),
1065 None => "null".to_string(),
1066 };
1067 let code = format!(
1068 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1069 js_string(ref_id),
1070 color_arg,
1071 label_arg
1072 );
1073 self.eval_bridge(&code, params.webview_label.as_deref())
1074 .await
1075 }
1076 InspectAction::ClearHighlights => {
1077 self.eval_bridge(
1078 "return window.__VICTAURI__?.clearHighlights()",
1079 params.webview_label.as_deref(),
1080 )
1081 .await
1082 }
1083 InspectAction::AuditAccessibility => {
1084 self.eval_bridge(
1085 "return window.__VICTAURI__?.auditAccessibility()",
1086 params.webview_label.as_deref(),
1087 )
1088 .await
1089 }
1090 InspectAction::GetPerformance => {
1091 self.eval_bridge(
1092 "return window.__VICTAURI__?.getPerformanceMetrics()",
1093 params.webview_label.as_deref(),
1094 )
1095 .await
1096 }
1097 }
1098 }
1099
1100 #[tool(
1101 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1102 annotations(
1103 read_only_hint = false,
1104 destructive_hint = false,
1105 idempotent_hint = true,
1106 open_world_hint = false
1107 )
1108 )]
1109 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1110 match params.action {
1111 CssAction::Inject => {
1112 if !self.state.privacy.is_tool_enabled("inject_css") {
1113 return tool_disabled("inject_css");
1114 }
1115 let Some(css) = ¶ms.css else {
1116 return missing_param("css", "inject");
1117 };
1118 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1119 self.eval_bridge(&code, params.webview_label.as_deref())
1120 .await
1121 }
1122 CssAction::Remove => {
1123 self.eval_bridge(
1124 "return window.__VICTAURI__?.removeInjectedCss()",
1125 params.webview_label.as_deref(),
1126 )
1127 .await
1128 }
1129 }
1130 }
1131
1132 #[tool(
1133 description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls).",
1134 annotations(
1135 read_only_hint = true,
1136 destructive_hint = false,
1137 idempotent_hint = true,
1138 open_world_hint = false
1139 )
1140 )]
1141 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1142 match params.action {
1143 LogsAction::Console => {
1144 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1145 let code = if since_arg.is_empty() {
1146 "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1147 } else {
1148 format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1149 };
1150 self.eval_bridge(&code, params.webview_label.as_deref())
1151 .await
1152 }
1153 LogsAction::Network => {
1154 let filter_arg = params
1155 .filter
1156 .as_ref()
1157 .map_or_else(|| "null".to_string(), |f| js_string(f));
1158 let limit_arg = params
1159 .limit
1160 .map_or_else(|| "null".to_string(), |l| l.to_string());
1161 let code =
1162 format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1163 self.eval_bridge(&code, params.webview_label.as_deref())
1164 .await
1165 }
1166 LogsAction::Ipc => {
1167 let wait = params.wait_for_capture.unwrap_or(false);
1168 let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1169 if wait {
1170 let limit_js = if limit_arg.is_empty() {
1171 "undefined".to_string()
1172 } else {
1173 limit_arg.clone()
1174 };
1175 let code = format!(
1176 r"return (async function() {{
1177 await window.__VICTAURI__.waitForIpcComplete(500);
1178 var log = window.__VICTAURI__.getIpcLog() || [];
1179 var lim = {limit_js};
1180 return (lim !== undefined) ? log.slice(-lim) : log;
1181 }})()"
1182 );
1183 let timeout = std::time::Duration::from_millis(5000);
1184 match self
1185 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1186 .await
1187 {
1188 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1189 Err(e) => tool_error(e),
1190 }
1191 } else {
1192 let code = if limit_arg.is_empty() {
1193 "return window.__VICTAURI__?.getIpcLog()".to_string()
1194 } else {
1195 format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1196 };
1197 self.eval_bridge(&code, params.webview_label.as_deref())
1198 .await
1199 }
1200 }
1201 LogsAction::Navigation => {
1202 self.eval_bridge(
1203 "return window.__VICTAURI__?.getNavigationLog()",
1204 params.webview_label.as_deref(),
1205 )
1206 .await
1207 }
1208 LogsAction::Dialogs => {
1209 self.eval_bridge(
1210 "return window.__VICTAURI__?.getDialogLog()",
1211 params.webview_label.as_deref(),
1212 )
1213 .await
1214 }
1215 LogsAction::Events => {
1216 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1217 let code = if since_arg.is_empty() {
1218 "return window.__VICTAURI__?.getEventStream()".to_string()
1219 } else {
1220 format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1221 };
1222 self.eval_bridge(&code, params.webview_label.as_deref())
1223 .await
1224 }
1225 LogsAction::SlowIpc => {
1226 let Some(threshold) = params.threshold_ms else {
1227 return missing_param("threshold_ms", "slow_ipc");
1228 };
1229 let limit = params.limit.unwrap_or(20);
1230 let code = format!(
1231 r"return (function() {{
1232 var log = window.__VICTAURI__?.getIpcLog() || [];
1233 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1234 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1235 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1236 }})()",
1237 );
1238 self.eval_bridge(&code, None).await
1239 }
1240 }
1241 }
1242}
1243
1244impl VictauriMcpHandler {
1245 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1247 Self {
1248 state,
1249 bridge,
1250 subscriptions: Arc::new(Mutex::new(HashSet::new())),
1251 bridge_checked: Arc::new(AtomicBool::new(false)),
1252 }
1253 }
1254
1255 fn track_tool_call(&self) {
1256 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1257 }
1258
1259 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
1260 match self.eval_with_return(code, webview_label).await {
1261 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1262 Err(e) => tool_error(e),
1263 }
1264 }
1265
1266 async fn eval_with_return(
1267 &self,
1268 code: &str,
1269 webview_label: Option<&str>,
1270 ) -> Result<String, String> {
1271 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1272 .await
1273 }
1274
1275 async fn eval_with_return_timeout(
1276 &self,
1277 code: &str,
1278 webview_label: Option<&str>,
1279 timeout: std::time::Duration,
1280 ) -> Result<String, String> {
1281 self.track_tool_call();
1282 let id = uuid::Uuid::new_v4().to_string();
1283 let (tx, rx) = tokio::sync::oneshot::channel();
1284
1285 {
1286 let mut pending = self.state.pending_evals.lock().await;
1287 if pending.len() >= MAX_PENDING_EVALS {
1288 return Err(format!(
1289 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1290 ));
1291 }
1292 pending.insert(id.clone(), tx);
1293 }
1294
1295 let code = code.trim();
1299 let needs_return = !code.starts_with("return ")
1300 && !code.starts_with("return;")
1301 && !code.starts_with('{')
1302 && !code.starts_with("if ")
1303 && !code.starts_with("if(")
1304 && !code.starts_with("for ")
1305 && !code.starts_with("for(")
1306 && !code.starts_with("while ")
1307 && !code.starts_with("while(")
1308 && !code.starts_with("switch ")
1309 && !code.starts_with("try ")
1310 && !code.starts_with("const ")
1311 && !code.starts_with("let ")
1312 && !code.starts_with("var ")
1313 && !code.starts_with("function ")
1314 && !code.starts_with("class ")
1315 && !code.starts_with("throw ");
1316 let code = if needs_return {
1317 format!("return {code}")
1318 } else {
1319 code.to_string()
1320 };
1321
1322 let inject = format!(
1323 r"
1324 (async () => {{
1325 try {{
1326 const __result = await (async () => {{ {code} }})();
1327 await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1328 id: '{id}',
1329 result: JSON.stringify(__result)
1330 }});
1331 }} catch (e) {{
1332 await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1333 id: '{id}',
1334 result: JSON.stringify({{ __error: e.message }})
1335 }});
1336 }}
1337 }})();
1338 "
1339 );
1340
1341 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
1342 self.state.pending_evals.lock().await.remove(&id);
1343 return Err(format!("eval injection failed: {e}"));
1344 }
1345
1346 match tokio::time::timeout(timeout, rx).await {
1347 Ok(Ok(result)) => {
1348 self.check_bridge_version_once();
1349 Ok(result)
1350 }
1351 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
1352 Err(_) => {
1353 self.state.pending_evals.lock().await.remove(&id);
1354 Err(format!("eval timed out after {}s", timeout.as_secs()))
1355 }
1356 }
1357 }
1358
1359 fn check_bridge_version_once(&self) {
1360 if self.bridge_checked.swap(true, Ordering::Relaxed) {
1361 return;
1362 }
1363 let handler = self.clone();
1364 tokio::spawn(async move {
1365 match handler
1366 .eval_with_return_timeout(
1367 "window.__VICTAURI__?.version",
1368 None,
1369 std::time::Duration::from_secs(5),
1370 )
1371 .await
1372 {
1373 Ok(v) => {
1374 let v = v.trim_matches('"');
1375 if v == BRIDGE_VERSION {
1376 tracing::debug!("Bridge version verified: {v}");
1377 } else {
1378 tracing::warn!(
1379 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
1380 );
1381 }
1382 }
1383 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
1384 }
1385 });
1386 }
1387}
1388
1389const SERVER_INSTRUCTIONS: &str = "Victauri gives you X-ray vision and hands inside a running Tauri application. \
1390Use compound tools with an 'action' parameter to interact with the app: \
1391'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
1392'window' (get_state, list, manage, resize, move_to, set_title), \
1393'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
1394set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
1395get_events, events_between, get_replay, export, import), 'inspect' (get_styles, \
1396get_bounding_boxes, highlight, clear_highlights, audit_accessibility, get_performance), \
1397'css' (inject, remove), 'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
1398Standalone tools: eval_js, dom_snapshot, invoke_command, screenshot, verify_state, \
1399detect_ghost_commands, check_ipc_integrity, wait_for, assert_semantic, resolve_command, \
1400get_registry, get_memory_stats, get_plugin_info.";
1401
1402impl ServerHandler for VictauriMcpHandler {
1403 fn get_info(&self) -> ServerInfo {
1404 ServerInfo::new(
1405 ServerCapabilities::builder()
1406 .enable_tools()
1407 .enable_resources()
1408 .enable_resources_subscribe()
1409 .build(),
1410 )
1411 .with_instructions(SERVER_INSTRUCTIONS)
1412 }
1413
1414 async fn list_tools(
1415 &self,
1416 _request: Option<PaginatedRequestParams>,
1417 _context: RequestContext<RoleServer>,
1418 ) -> Result<ListToolsResult, ErrorData> {
1419 let all_tools = Self::tool_router().list_all();
1420 let filtered: Vec<Tool> = all_tools
1421 .into_iter()
1422 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1423 .collect();
1424 Ok(ListToolsResult {
1425 tools: filtered,
1426 ..Default::default()
1427 })
1428 }
1429
1430 async fn call_tool(
1431 &self,
1432 request: CallToolRequestParams,
1433 context: RequestContext<RoleServer>,
1434 ) -> Result<CallToolResult, ErrorData> {
1435 let tool_name: String = request.name.as_ref().to_owned();
1436 if !self.state.privacy.is_tool_enabled(&tool_name) {
1437 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
1438 return Ok(tool_disabled(&tool_name));
1439 }
1440 self.state
1441 .tool_invocations
1442 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1443 let start = std::time::Instant::now();
1444 tracing::debug!(tool = %tool_name, "tool invocation started");
1445 let ctx = ToolCallContext::new(self, request, context);
1446 let result = Self::tool_router().call(ctx).await;
1447 let elapsed = start.elapsed();
1448 tracing::debug!(
1449 tool = %tool_name,
1450 elapsed_ms = elapsed.as_millis() as u64,
1451 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
1452 "tool invocation completed"
1453 );
1454
1455 if self.state.privacy.redaction_enabled {
1458 result.map(|mut r| {
1459 for item in &mut r.content {
1460 if let RawContent::Text(ref mut tc) = item.raw {
1461 tc.text = self.state.privacy.redact_output(&tc.text);
1462 }
1463 }
1464 r
1465 })
1466 } else {
1467 result
1468 }
1469 }
1470
1471 fn get_tool(&self, name: &str) -> Option<Tool> {
1472 if !self.state.privacy.is_tool_enabled(name) {
1473 return None;
1474 }
1475 Self::tool_router().get(name).cloned()
1476 }
1477
1478 async fn list_resources(
1479 &self,
1480 _request: Option<PaginatedRequestParams>,
1481 _context: RequestContext<RoleServer>,
1482 ) -> Result<ListResourcesResult, ErrorData> {
1483 Ok(ListResourcesResult {
1484 resources: vec![
1485 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
1486 .with_description(
1487 "Live IPC call log — all commands invoked between frontend and backend",
1488 )
1489 .with_mime_type("application/json")
1490 .no_annotation(),
1491 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
1492 .with_description(
1493 "Current state of all Tauri windows — position, size, visibility, focus",
1494 )
1495 .with_mime_type("application/json")
1496 .no_annotation(),
1497 RawResource::new(RESOURCE_URI_STATE, "state")
1498 .with_description(
1499 "Victauri plugin state — event count, registered commands, memory stats",
1500 )
1501 .with_mime_type("application/json")
1502 .no_annotation(),
1503 ],
1504 ..Default::default()
1505 })
1506 }
1507
1508 async fn read_resource(
1509 &self,
1510 request: ReadResourceRequestParams,
1511 _context: RequestContext<RoleServer>,
1512 ) -> Result<ReadResourceResult, ErrorData> {
1513 let uri = &request.uri;
1514 let json = match uri.as_str() {
1515 RESOURCE_URI_IPC_LOG => {
1516 if let Ok(json) = self
1517 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
1518 .await
1519 {
1520 json
1521 } else {
1522 let calls = self.state.event_log.ipc_calls();
1523 serde_json::to_string_pretty(&calls)
1524 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1525 }
1526 }
1527 RESOURCE_URI_WINDOWS => {
1528 let states = self.bridge.get_window_states(None);
1529 serde_json::to_string_pretty(&states)
1530 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1531 }
1532 RESOURCE_URI_STATE => {
1533 let state_json = serde_json::json!({
1534 "events_captured": self.state.event_log.len(),
1535 "commands_registered": self.state.registry.count(),
1536 "memory": crate::memory::current_stats(),
1537 "port": self.state.port.load(Ordering::Relaxed),
1538 });
1539 serde_json::to_string_pretty(&state_json)
1540 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1541 }
1542 _ => {
1543 return Err(ErrorData::resource_not_found(
1544 format!("unknown resource: {uri}"),
1545 None,
1546 ));
1547 }
1548 };
1549
1550 Ok(ReadResourceResult::new(vec![ResourceContents::text(
1551 json, uri,
1552 )]))
1553 }
1554
1555 async fn subscribe(
1556 &self,
1557 request: SubscribeRequestParams,
1558 _context: RequestContext<RoleServer>,
1559 ) -> Result<(), ErrorData> {
1560 let uri = &request.uri;
1561 match uri.as_str() {
1562 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
1563 self.subscriptions.lock().await.insert(uri.clone());
1564 tracing::info!("Client subscribed to resource: {uri}");
1565 Ok(())
1566 }
1567 _ => Err(ErrorData::resource_not_found(
1568 format!("unknown resource: {uri}"),
1569 None,
1570 )),
1571 }
1572 }
1573
1574 async fn unsubscribe(
1575 &self,
1576 request: UnsubscribeRequestParams,
1577 _context: RequestContext<RoleServer>,
1578 ) -> Result<(), ErrorData> {
1579 self.subscriptions.lock().await.remove(&request.uri);
1580 tracing::info!("Client unsubscribed from resource: {}", request.uri);
1581 Ok(())
1582 }
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587 use super::*;
1588
1589 #[test]
1590 fn js_string_simple() {
1591 assert_eq!(js_string("hello"), "\"hello\"");
1592 }
1593
1594 #[test]
1595 fn js_string_single_quotes() {
1596 let result = js_string("it's a test");
1597 assert!(result.contains("it's a test"));
1598 }
1599
1600 #[test]
1601 fn js_string_double_quotes() {
1602 let result = js_string(r#"say "hello""#);
1603 assert!(result.contains(r#"\""#));
1604 }
1605
1606 #[test]
1607 fn js_string_backslashes() {
1608 let result = js_string(r"path\to\file");
1609 assert!(result.contains(r"\\"));
1610 }
1611
1612 #[test]
1613 fn js_string_newlines_and_tabs() {
1614 let result = js_string("line1\nline2\ttab");
1615 assert!(result.contains(r"\n"));
1616 assert!(result.contains(r"\t"));
1617 assert!(!result.contains('\n'));
1618 }
1619
1620 #[test]
1621 fn js_string_null_bytes() {
1622 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
1623 let result = js_string(&input);
1624 assert!(result.contains("\\u0000"));
1626 assert!(!result.contains('\0'));
1627 }
1628
1629 #[test]
1630 fn js_string_template_literal_injection() {
1631 let result = js_string("`${alert(1)}`");
1632 assert!(result.starts_with('"'));
1635 assert!(result.ends_with('"'));
1636 }
1637
1638 #[test]
1639 fn js_string_unicode_separators() {
1640 let result = js_string("a\u{2028}b\u{2029}c");
1645 let decoded: String = serde_json::from_str(&result).unwrap();
1647 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
1648 }
1649
1650 #[test]
1651 fn js_string_empty() {
1652 assert_eq!(js_string(""), "\"\"");
1653 }
1654
1655 #[test]
1656 fn js_string_html_script_close() {
1657 let result = js_string("</script><img onerror=alert(1)>");
1659 assert!(result.starts_with('"'));
1660 let decoded: String = serde_json::from_str(&result).unwrap();
1662 assert_eq!(decoded, "</script><img onerror=alert(1)>");
1663 }
1664
1665 #[test]
1666 fn js_string_very_long() {
1667 let long = "a".repeat(100_000);
1668 let result = js_string(&long);
1669 assert!(result.len() >= 100_002); }
1671
1672 #[test]
1675 fn url_allows_http() {
1676 assert!(validate_url("http://example.com", false).is_ok());
1677 }
1678
1679 #[test]
1680 fn url_allows_https() {
1681 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
1682 }
1683
1684 #[test]
1685 fn url_allows_http_localhost() {
1686 assert!(validate_url("http://localhost:3000", false).is_ok());
1687 }
1688
1689 #[test]
1690 fn url_blocks_file_by_default() {
1691 let err = validate_url("file:///etc/passwd", false).unwrap_err();
1692 assert!(err.contains("file"), "error should mention the file scheme");
1693 }
1694
1695 #[test]
1696 fn url_allows_file_when_opted_in() {
1697 assert!(validate_url("file:///tmp/test.html", true).is_ok());
1698 }
1699
1700 #[test]
1701 fn url_blocks_javascript() {
1702 assert!(validate_url("javascript:alert(1)", false).is_err());
1703 }
1704
1705 #[test]
1706 fn url_blocks_javascript_case_insensitive() {
1707 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
1708 }
1709
1710 #[test]
1711 fn url_blocks_data_scheme() {
1712 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
1713 }
1714
1715 #[test]
1716 fn url_blocks_vbscript() {
1717 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
1718 }
1719
1720 #[test]
1721 fn url_rejects_invalid() {
1722 assert!(validate_url("not a url at all", false).is_err());
1723 }
1724
1725 #[test]
1726 fn url_strips_control_chars() {
1727 let input = format!("http://example{}com", '\0');
1729 assert!(validate_url(&input, false).is_ok());
1730 }
1731
1732 #[test]
1735 fn css_color_valid_hex() {
1736 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
1737 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
1738 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
1739 }
1740
1741 #[test]
1742 fn css_color_valid_rgb() {
1743 assert_eq!(
1744 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
1745 "rgb(255, 0, 0)"
1746 );
1747 assert_eq!(
1748 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
1749 "rgba(0, 0, 0, 0.5)"
1750 );
1751 }
1752
1753 #[test]
1754 fn css_color_valid_named() {
1755 assert_eq!(sanitize_css_color("red").unwrap(), "red");
1756 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
1757 }
1758
1759 #[test]
1760 fn css_color_valid_hsl() {
1761 assert_eq!(
1762 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
1763 "hsl(120, 50%, 50%)"
1764 );
1765 }
1766
1767 #[test]
1768 fn css_color_rejects_too_long() {
1769 let long = "a".repeat(101);
1770 assert!(sanitize_css_color(&long).is_err());
1771 }
1772
1773 #[test]
1774 fn css_color_rejects_backslash_escapes() {
1775 assert!(sanitize_css_color(r"red\00").is_err());
1776 assert!(sanitize_css_color(r"\72\65\64").is_err());
1777 }
1778
1779 #[test]
1780 fn css_color_rejects_url_injection() {
1781 assert!(sanitize_css_color("url(http://evil.com)").is_err());
1782 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
1783 }
1784
1785 #[test]
1786 fn css_color_rejects_expression_injection() {
1787 assert!(sanitize_css_color("expression(alert(1))").is_err());
1788 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
1789 }
1790
1791 #[test]
1792 fn css_color_rejects_import() {
1793 assert!(sanitize_css_color("@import url(evil.css)").is_err());
1794 }
1795
1796 #[test]
1797 fn css_color_rejects_semicolons_and_braces() {
1798 assert!(sanitize_css_color("red; background: url(evil)").is_err());
1799 assert!(sanitize_css_color("red} body { color: blue").is_err());
1800 }
1801
1802 #[test]
1803 fn css_color_rejects_special_chars() {
1804 assert!(sanitize_css_color("red<script>").is_err());
1805 assert!(sanitize_css_color("red\"onload=alert").is_err());
1806 assert!(sanitize_css_color("red'onclick=alert").is_err());
1807 }
1808
1809 #[test]
1810 fn css_color_trims_whitespace() {
1811 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
1812 }
1813
1814 #[test]
1815 fn css_color_empty_string() {
1816 assert_eq!(sanitize_css_color("").unwrap(), "");
1817 }
1818}