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