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