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.4.0";
60
61const SAFE_ENV_PREFIXES: &[&str] = &[
62 "PATH",
63 "HOME",
64 "USER",
65 "LANG",
66 "LC_",
67 "TERM",
68 "SHELL",
69 "DISPLAY",
70 "XDG_",
71 "TAURI_",
72 "VICTAURI_",
73 "RUST",
74 "CARGO",
75 "NODE_ENV",
76 "APPDATA",
77 "LOCALAPPDATA",
78 "USERPROFILE",
79 "TEMP",
80 "TMP",
81 "PROGRAMFILES",
82 "SYSTEMROOT",
83 "WINDIR",
84 "COMSPEC",
85 "OS",
86 "PROCESSOR_",
87 "NUMBER_OF_PROCESSORS",
88 "COMPUTERNAME",
89 "HOSTNAME",
90 "PWD",
91 "OLDPWD",
92 "SHLVL",
93 "LOGNAME",
94];
95
96#[derive(Clone)]
98pub struct VictauriMcpHandler {
99 state: Arc<VictauriState>,
100 bridge: Arc<dyn WebviewBridge>,
101 subscriptions: Arc<Mutex<HashSet<String>>>,
102 bridge_checked: Arc<AtomicBool>,
103}
104
105#[tool_router]
106impl VictauriMcpHandler {
107 #[tool(
110 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
111 annotations(
112 read_only_hint = false,
113 destructive_hint = true,
114 idempotent_hint = false,
115 open_world_hint = false
116 )
117 )]
118 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
119 if !self.state.privacy.is_tool_enabled("eval_js") {
120 return tool_disabled("eval_js");
121 }
122 if params.code.len() > MAX_EVAL_CODE_LEN {
123 return tool_error("code exceeds maximum length (1 MB)");
124 }
125 match self
126 .eval_with_return(¶ms.code, params.webview_label.as_deref())
127 .await
128 {
129 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
130 Err(e) => tool_error(e),
131 }
132 }
133
134 #[tool(
135 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).",
136 annotations(
137 read_only_hint = true,
138 destructive_hint = false,
139 idempotent_hint = true,
140 open_world_hint = false
141 )
142 )]
143 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
144 let format = params.format.unwrap_or(SnapshotFormat::Compact);
145 let format_str = match format {
146 SnapshotFormat::Compact => "compact",
147 SnapshotFormat::Json => "json",
148 };
149 let code = format!(
150 "return window.__VICTAURI__?.snapshot({})",
151 js_string(format_str)
152 );
153 self.eval_bridge(&code, params.webview_label.as_deref())
154 .await
155 }
156
157 #[tool(
158 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.",
159 annotations(
160 read_only_hint = true,
161 destructive_hint = false,
162 idempotent_hint = true,
163 open_world_hint = false
164 )
165 )]
166 async fn find_elements(
167 &self,
168 Parameters(params): Parameters<FindElementsParams>,
169 ) -> CallToolResult {
170 let mut parts: Vec<String> = Vec::new();
171 if let Some(t) = ¶ms.text {
172 parts.push(format!("text: {}", js_string(t)));
173 }
174 if let Some(r) = ¶ms.role {
175 parts.push(format!("role: {}", js_string(r)));
176 }
177 if let Some(tid) = ¶ms.test_id {
178 parts.push(format!("test_id: {}", js_string(tid)));
179 }
180 if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
181 parts.push(format!("css: {}", js_string(c)));
182 }
183 if let Some(n) = ¶ms.name {
184 parts.push(format!("name: {}", js_string(n)));
185 }
186 if let Some(max) = params.max_results {
187 parts.push(format!("max_results: {max}"));
188 }
189 if let Some(t) = ¶ms.tag {
190 parts.push(format!("tag: {}", js_string(t)));
191 }
192 if let Some(p) = ¶ms.placeholder {
193 parts.push(format!("placeholder: {}", js_string(p)));
194 }
195 if let Some(a) = ¶ms.alt {
196 parts.push(format!("alt: {}", js_string(a)));
197 }
198 if let Some(ta) = ¶ms.title_attr {
199 parts.push(format!("title_attr: {}", js_string(ta)));
200 }
201 if let Some(l) = ¶ms.label {
202 parts.push(format!("label: {}", js_string(l)));
203 }
204 if let Some(true) = params.exact {
205 parts.push("exact: true".to_string());
206 }
207 if let Some(e) = params.enabled {
208 parts.push(format!("enabled: {e}"));
209 }
210 let code = format!(
211 "return window.__VICTAURI__?.findElements({{ {} }})",
212 parts.join(", ")
213 );
214 self.eval_bridge(&code, params.webview_label.as_deref())
215 .await
216 }
217
218 #[tool(
219 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.",
220 annotations(
221 read_only_hint = false,
222 destructive_hint = true,
223 idempotent_hint = false,
224 open_world_hint = false
225 )
226 )]
227 async fn invoke_command(
228 &self,
229 Parameters(params): Parameters<InvokeCommandParams>,
230 ) -> CallToolResult {
231 if !self.state.privacy.is_invoke_allowed(¶ms.command) {
232 return tool_disabled("invoke_command");
233 }
234 if !self.state.privacy.is_command_allowed(¶ms.command) {
235 return tool_error(format!(
236 "command '{}' is blocked by privacy configuration",
237 params.command
238 ));
239 }
240 let args_json = params.args.unwrap_or(serde_json::json!({}));
241 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
242 let code = format!(
243 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
244 js_string(¶ms.command)
245 );
246 match self
247 .eval_with_return(&code, params.webview_label.as_deref())
248 .await
249 {
250 Ok(result) => {
251 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
252 && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
253 {
254 return tool_error(format!(
255 "command '{}' returned error: {err}",
256 params.command
257 ));
258 }
259 CallToolResult::success(vec![Content::text(result)])
260 }
261 Err(e) => tool_error(format!("invoke_command failed: {e}")),
262 }
263 }
264
265 #[tool(
266 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
267 annotations(
268 read_only_hint = true,
269 destructive_hint = false,
270 idempotent_hint = true,
271 open_world_hint = false
272 )
273 )]
274 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
275 self.track_tool_call();
276 if !self.state.privacy.is_tool_enabled("screenshot") {
277 return tool_disabled("screenshot");
278 }
279 match self
280 .bridge
281 .get_native_handle(params.window_label.as_deref())
282 {
283 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
284 Ok(png_bytes) => {
285 use base64::Engine;
286 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
287 CallToolResult::success(vec![Content::image(b64, "image/png")])
288 }
289 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
290 },
291 Err(e) => tool_error(format!("cannot get window handle: {e}")),
292 }
293 }
294
295 #[tool(
296 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
297 annotations(
298 read_only_hint = true,
299 destructive_hint = false,
300 idempotent_hint = true,
301 open_world_hint = false
302 )
303 )]
304 async fn verify_state(
305 &self,
306 Parameters(params): Parameters<VerifyStateParams>,
307 ) -> CallToolResult {
308 if !self.state.privacy.is_tool_enabled("eval_js") {
309 return tool_disabled("verify_state requires eval_js capability");
310 }
311 let code = format!("return ({})", params.frontend_expr);
312 let frontend_json = match self
313 .eval_with_return(&code, params.webview_label.as_deref())
314 .await
315 {
316 Ok(result) => result,
317 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
318 };
319
320 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
321 Ok(v) => v,
322 Err(e) => {
323 return tool_error(format!(
324 "frontend expression did not return valid JSON: {e}"
325 ));
326 }
327 };
328
329 let backend_state = if let Some(state) = params.backend_state {
330 state
331 } else if let Some(ref cmd) = params.backend_command {
332 if !self.state.privacy.is_command_allowed(cmd) {
333 return tool_error(format!(
334 "command '{cmd}' is blocked by privacy configuration"
335 ));
336 }
337 let args = params.backend_args.unwrap_or(serde_json::json!({}));
338 let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
339 let invoke_code = format!(
340 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
341 js_string(cmd)
342 );
343 match self
344 .eval_with_return(&invoke_code, params.webview_label.as_deref())
345 .await
346 {
347 Ok(result) => match serde_json::from_str(&result) {
348 Ok(v) => v,
349 Err(e) => {
350 return tool_error(format!(
351 "backend command '{cmd}' did not return valid JSON: {e}"
352 ));
353 }
354 },
355 Err(e) => {
356 return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
357 }
358 }
359 } else {
360 return tool_error("either backend_state or backend_command must be provided");
361 };
362
363 let result = victauri_core::verify_state(frontend_state, backend_state);
364 json_result(&result)
365 }
366
367 #[tool(
368 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.",
369 annotations(
370 read_only_hint = true,
371 destructive_hint = false,
372 idempotent_hint = true,
373 open_world_hint = false
374 )
375 )]
376 async fn detect_ghost_commands(
377 &self,
378 Parameters(params): Parameters<GhostCommandParams>,
379 ) -> CallToolResult {
380 let code = "return window.__VICTAURI__?.getIpcLog()";
381 let ipc_json = match self
382 .eval_with_return(code, params.webview_label.as_deref())
383 .await
384 {
385 Ok(r) => r,
386 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
387 };
388
389 let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
390 Ok(v) => v,
391 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
392 };
393 let frontend_commands: Vec<String> = ipc_calls
394 .iter()
395 .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
396 .collect::<std::collections::HashSet<_>>()
397 .into_iter()
398 .collect();
399
400 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
401 json_result(&report)
402 }
403
404 #[tool(
405 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
406 annotations(
407 read_only_hint = true,
408 destructive_hint = false,
409 idempotent_hint = true,
410 open_world_hint = false
411 )
412 )]
413 async fn check_ipc_integrity(
414 &self,
415 Parameters(params): Parameters<IpcIntegrityParams>,
416 ) -> CallToolResult {
417 let threshold = params.stale_threshold_ms.unwrap_or(5000);
418 let code = format!(
419 r"return (function() {{
420 var log = window.__VICTAURI__?.getIpcLog() || [];
421 var now = Date.now();
422 var threshold = {threshold};
423 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
424 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
425 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
426 return {{
427 healthy: stale.length === 0 && errored.length === 0,
428 total_calls: log.length,
429 pending_count: pending.length,
430 stale_count: stale.length,
431 error_count: errored.length,
432 stale_calls: stale.slice(0, 20),
433 errored_calls: errored.slice(0, 20)
434 }};
435 }})()"
436 );
437 self.eval_bridge(&code, params.webview_label.as_deref())
438 .await
439 }
440
441 #[tool(
442 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).",
443 annotations(
444 read_only_hint = true,
445 destructive_hint = false,
446 idempotent_hint = true,
447 open_world_hint = false
448 )
449 )]
450 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
451 let value = params
452 .value
453 .as_ref()
454 .map_or_else(|| "null".to_string(), |v| js_string(v));
455 let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
456 let poll = params.poll_ms.unwrap_or(200);
457 let code = format!(
458 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
459 js_string(params.condition.as_str())
460 );
461 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
462 match self
463 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
464 .await
465 {
466 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
467 Err(e) => tool_error(e),
468 }
469 }
470
471 #[tool(
472 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.",
473 annotations(
474 read_only_hint = true,
475 destructive_hint = false,
476 idempotent_hint = true,
477 open_world_hint = false
478 )
479 )]
480 async fn assert_semantic(
481 &self,
482 Parameters(params): Parameters<SemanticAssertParams>,
483 ) -> CallToolResult {
484 if !self.state.privacy.is_tool_enabled("eval_js") {
485 return tool_disabled("assert_semantic requires eval_js capability");
486 }
487 let code = format!("return ({})", params.expression);
488 let actual_json = match self
489 .eval_with_return(&code, params.webview_label.as_deref())
490 .await
491 {
492 Ok(result) => result,
493 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
494 };
495
496 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
497 Ok(v) => v,
498 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
499 };
500
501 let assertion = victauri_core::SemanticAssertion {
502 label: params.label,
503 condition: params.condition,
504 expected: params.expected,
505 };
506
507 let result = victauri_core::evaluate_assertion(actual, &assertion);
508 json_result(&result)
509 }
510
511 #[tool(
512 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
513 annotations(
514 read_only_hint = true,
515 destructive_hint = false,
516 idempotent_hint = true,
517 open_world_hint = false
518 )
519 )]
520 async fn resolve_command(
521 &self,
522 Parameters(params): Parameters<ResolveCommandParams>,
523 ) -> CallToolResult {
524 self.track_tool_call();
525 let limit = params.limit.unwrap_or(5);
526 let mut results = self.state.registry.resolve(¶ms.query);
527 results.truncate(limit);
528 json_result(&results)
529 }
530
531 #[tool(
532 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.",
533 annotations(
534 read_only_hint = true,
535 destructive_hint = false,
536 idempotent_hint = true,
537 open_world_hint = false
538 )
539 )]
540 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
541 self.track_tool_call();
542 let commands = match params.query {
543 Some(q) => self.state.registry.search(&q),
544 None => self.state.registry.list(),
545 };
546 json_result(&commands)
547 }
548
549 #[tool(
550 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.",
551 annotations(
552 read_only_hint = true,
553 destructive_hint = false,
554 idempotent_hint = true,
555 open_world_hint = false
556 )
557 )]
558 async fn get_memory_stats(&self) -> CallToolResult {
559 self.track_tool_call();
560 let stats = crate::memory::current_stats();
561 json_result(&stats)
562 }
563
564 #[tool(
565 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.",
566 annotations(
567 read_only_hint = true,
568 destructive_hint = false,
569 idempotent_hint = true,
570 open_world_hint = false
571 )
572 )]
573 async fn get_plugin_info(&self) -> CallToolResult {
574 self.track_tool_call();
575 let disabled: Vec<&str> = self
576 .state
577 .privacy
578 .disabled_tools
579 .iter()
580 .map(std::string::String::as_str)
581 .collect();
582 let blocklist: Vec<&str> = self
583 .state
584 .privacy
585 .command_blocklist
586 .iter()
587 .map(std::string::String::as_str)
588 .collect();
589 let allowlist: Option<Vec<&str>> = self
590 .state
591 .privacy
592 .command_allowlist
593 .as_ref()
594 .map(|s| s.iter().map(std::string::String::as_str).collect());
595 let all_tools = Self::tool_router().list_all();
596 let enabled_tools: Vec<&str> = all_tools
597 .iter()
598 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
599 .map(|t| t.name.as_ref())
600 .collect();
601
602 let result = serde_json::json!({
603 "version": env!("CARGO_PKG_VERSION"),
604 "bridge_version": BRIDGE_VERSION,
605 "port": self.state.port.load(Ordering::Relaxed),
606 "tools": {
607 "total": all_tools.len(),
608 "enabled": enabled_tools.len(),
609 "enabled_list": enabled_tools,
610 "disabled_list": disabled,
611 },
612 "commands": {
613 "allowlist": allowlist,
614 "blocklist": blocklist,
615 },
616 "privacy": {
617 "profile": self.state.privacy.profile.to_string(),
618 "redaction_enabled": self.state.privacy.redaction_enabled,
619 },
620 "capacities": {
621 "event_log": self.state.event_log.capacity(),
622 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
623 },
624 "registered_commands": self.state.registry.count(),
625 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
626 "uptime_secs": self.state.started_at.elapsed().as_secs(),
627 });
628 json_result(&result)
629 }
630
631 #[tool(
632 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.",
633 annotations(
634 read_only_hint = true,
635 destructive_hint = false,
636 idempotent_hint = true,
637 open_world_hint = false
638 )
639 )]
640 async fn get_diagnostics(
641 &self,
642 Parameters(params): Parameters<DiagnosticsParams>,
643 ) -> CallToolResult {
644 self.eval_bridge(
645 "return window.__VICTAURI__?.getDiagnostics()",
646 params.webview_label.as_deref(),
647 )
648 .await
649 }
650
651 #[tool(
654 description = "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview.",
655 annotations(
656 read_only_hint = true,
657 destructive_hint = false,
658 idempotent_hint = true,
659 open_world_hint = false
660 )
661 )]
662 async fn app_info(&self) -> CallToolResult {
663 self.track_tool_call();
664 let config = self.bridge.tauri_config();
665
666 let data_dir = self.bridge.app_data_dir().ok();
667 let config_dir = self.bridge.app_config_dir().ok();
668 let log_dir = self.bridge.app_log_dir().ok();
669 let local_data_dir = self.bridge.app_local_data_dir().ok();
670
671 let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
672 .filter(|(k, _)| {
673 let upper = k.to_uppercase();
674 SAFE_ENV_PREFIXES
675 .iter()
676 .any(|prefix| upper.starts_with(prefix))
677 })
678 .collect();
679
680 #[cfg(feature = "sqlite")]
681 let databases: Vec<String> = data_dir
682 .as_ref()
683 .map(|d| {
684 crate::database::discover_databases(d)
685 .into_iter()
686 .filter_map(|p| {
687 p.strip_prefix(d)
688 .ok()
689 .map(|rel| rel.to_string_lossy().into_owned())
690 })
691 .collect()
692 })
693 .unwrap_or_default();
694
695 #[cfg(not(feature = "sqlite"))]
696 let databases: Vec<String> = Vec::new();
697
698 let result = serde_json::json!({
699 "config": config,
700 "paths": {
701 "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
702 "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
703 "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
704 "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
705 },
706 "databases": databases,
707 "env": env_vars,
708 "process": {
709 "pid": std::process::id(),
710 "arch": std::env::consts::ARCH,
711 "os": std::env::consts::OS,
712 "family": std::env::consts::FAMILY,
713 },
714 });
715 json_result(&result)
716 }
717
718 #[tool(
719 description = "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview.",
720 annotations(
721 read_only_hint = true,
722 destructive_hint = false,
723 idempotent_hint = true,
724 open_world_hint = false
725 )
726 )]
727 async fn list_app_dir(
728 &self,
729 Parameters(params): Parameters<ListAppDirParams>,
730 ) -> CallToolResult {
731 self.track_tool_call();
732 let base = match self.resolve_app_dir(params.directory) {
733 Ok(d) => d,
734 Err(e) => return tool_error(e),
735 };
736
737 let target = if let Some(ref sub) = params.path {
738 let resolved = base.join(sub);
739 if !resolved.exists() {
740 return tool_error(format!("directory does not exist: {}", resolved.display()));
741 }
742 if let Err(e) = Self::safe_within(&base, &resolved) {
743 return tool_error(e);
744 }
745 resolved
746 } else {
747 base.clone()
748 };
749
750 if !target.exists() {
751 return tool_error(format!("directory does not exist: {}", target.display()));
752 }
753
754 let max_depth = params.max_depth.unwrap_or(1).min(5);
755 let pattern = params.pattern.as_deref();
756 let mut entries = Vec::new();
757
758 Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
759
760 json_result(&serde_json::json!({
761 "base": base.to_string_lossy(),
762 "path": params.path.unwrap_or_default(),
763 "entries": entries,
764 "count": entries.len(),
765 }))
766 }
767
768 #[tool(
769 description = "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview.",
770 annotations(
771 read_only_hint = true,
772 destructive_hint = false,
773 idempotent_hint = true,
774 open_world_hint = false
775 )
776 )]
777 async fn read_app_file(
778 &self,
779 Parameters(params): Parameters<ReadAppFileParams>,
780 ) -> CallToolResult {
781 self.track_tool_call();
782 let base = match self.resolve_app_dir(params.directory) {
783 Ok(d) => d,
784 Err(e) => return tool_error(e),
785 };
786
787 let target = base.join(¶ms.path);
788 if !target.exists() {
789 return tool_error(format!("file not found: {}", params.path));
790 }
791 if let Err(e) = Self::safe_within(&base, &target) {
792 return tool_error(e);
793 }
794 if !target.is_file() {
795 return tool_error(format!("not a file: {}", params.path));
796 }
797
798 let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
799 let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
800
801 match std::fs::read(&target) {
802 Ok(mut bytes) => {
803 let original_size = bytes.len();
804 let truncated = bytes.len() > max_bytes;
805 if truncated {
806 bytes.truncate(max_bytes);
807 }
808
809 let file_info = serde_json::json!({
810 "path": params.path,
811 "size": original_size,
812 "truncated": truncated,
813 "modified": metadata.as_ref().ok()
814 .and_then(|m| m.modified().ok())
815 .map(|t| {
816 let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
817 duration.as_secs()
818 }),
819 });
820
821 if params.binary == Some(true) {
822 use base64::Engine;
823 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
824 json_result(&serde_json::json!({
825 "file": file_info,
826 "encoding": "base64",
827 "content": b64,
828 }))
829 } else {
830 match String::from_utf8(bytes) {
831 Ok(text) => json_result(&serde_json::json!({
832 "file": file_info,
833 "encoding": "utf-8",
834 "content": text,
835 })),
836 Err(e) => {
837 use base64::Engine;
838 let bytes = e.into_bytes();
839 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
840 json_result(&serde_json::json!({
841 "file": file_info,
842 "encoding": "base64",
843 "note": "file is not valid UTF-8, returning base64",
844 "content": b64,
845 }))
846 }
847 }
848 }
849 }
850 Err(e) => tool_error(format!("failed to read file: {e}")),
851 }
852 }
853
854 #[cfg(feature = "sqlite")]
855 #[tool(
856 description = "Execute a read-only SQL query against a SQLite database in the app's data directory. Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
857 annotations(
858 read_only_hint = true,
859 destructive_hint = false,
860 idempotent_hint = true,
861 open_world_hint = false
862 )
863 )]
864 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
865 self.track_tool_call();
866 let data_dir = match self.bridge.app_data_dir() {
867 Ok(d) => d,
868 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
869 };
870
871 let db_path = if let Some(ref rel_path) = params.path {
872 let resolved = data_dir.join(rel_path);
873 if !resolved.exists() {
874 return tool_error(format!("database not found: {rel_path}"));
875 }
876 if let Err(e) = Self::safe_within(&data_dir, &resolved) {
877 return tool_error(e);
878 }
879 resolved
880 } else {
881 let databases = crate::database::discover_databases(&data_dir);
882 match databases.first() {
883 Some(p) => p.clone(),
884 None => {
885 return tool_error(format!(
886 "no SQLite databases found in {}",
887 data_dir.display()
888 ));
889 }
890 }
891 };
892
893 let db_display = db_path
894 .strip_prefix(&data_dir)
895 .unwrap_or(&db_path)
896 .to_string_lossy()
897 .into_owned();
898 let bind_params = params.params.unwrap_or_default();
899
900 match crate::database::query(&db_path, ¶ms.query, &bind_params, params.max_rows) {
901 Ok(mut result) => {
902 if let Some(obj) = result.as_object_mut() {
903 obj.insert("database".to_string(), serde_json::json!(db_display));
904 }
905 json_result(&result)
906 }
907 Err(e) => tool_error(e),
908 }
909 }
910
911 #[tool(
914 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.",
915 annotations(
916 read_only_hint = false,
917 destructive_hint = false,
918 idempotent_hint = false,
919 open_world_hint = false
920 )
921 )]
922 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
923 if !self.state.privacy.is_tool_enabled("interact") {
924 return tool_disabled("interact");
925 }
926 match params.action {
927 InteractAction::Click => {
928 if !self.state.privacy.is_tool_enabled("interact.click") {
929 return tool_disabled("interact.click");
930 }
931 let Some(ref_id) = ¶ms.ref_id else {
932 return missing_param("ref_id", "click");
933 };
934 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
935 self.eval_bridge(&code, params.webview_label.as_deref())
936 .await
937 }
938 InteractAction::DoubleClick => {
939 if !self.state.privacy.is_tool_enabled("interact.double_click") {
940 return tool_disabled("interact.double_click");
941 }
942 let Some(ref_id) = ¶ms.ref_id else {
943 return missing_param("ref_id", "double_click");
944 };
945 let code = format!(
946 "return window.__VICTAURI__?.doubleClick({})",
947 js_string(ref_id)
948 );
949 self.eval_bridge(&code, params.webview_label.as_deref())
950 .await
951 }
952 InteractAction::Hover => {
953 if !self.state.privacy.is_tool_enabled("interact.hover") {
954 return tool_disabled("interact.hover");
955 }
956 let Some(ref_id) = ¶ms.ref_id else {
957 return missing_param("ref_id", "hover");
958 };
959 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
960 self.eval_bridge(&code, params.webview_label.as_deref())
961 .await
962 }
963 InteractAction::Focus => {
964 if !self.state.privacy.is_tool_enabled("interact.focus") {
965 return tool_disabled("interact.focus");
966 }
967 let Some(ref_id) = ¶ms.ref_id else {
968 return missing_param("ref_id", "focus");
969 };
970 let code = format!(
971 "return window.__VICTAURI__?.focusElement({})",
972 js_string(ref_id)
973 );
974 self.eval_bridge(&code, params.webview_label.as_deref())
975 .await
976 }
977 InteractAction::ScrollIntoView => {
978 if !self
979 .state
980 .privacy
981 .is_tool_enabled("interact.scroll_into_view")
982 {
983 return tool_disabled("interact.scroll_into_view");
984 }
985 let ref_arg = params
986 .ref_id
987 .as_ref()
988 .map_or_else(|| "null".to_string(), |r| js_string(r));
989 let x = params.x.unwrap_or(0.0);
990 let y = params.y.unwrap_or(0.0);
991 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
992 self.eval_bridge(&code, params.webview_label.as_deref())
993 .await
994 }
995 InteractAction::SelectOption => {
996 if !self.state.privacy.is_tool_enabled("interact.select_option") {
997 return tool_disabled("interact.select_option");
998 }
999 let Some(ref_id) = ¶ms.ref_id else {
1000 return missing_param("ref_id", "select_option");
1001 };
1002 let values = params.values.as_deref().unwrap_or(&[]);
1003 let values_json =
1004 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1005 let code = format!(
1006 "return window.__VICTAURI__?.selectOption({}, {})",
1007 js_string(ref_id),
1008 values_json
1009 );
1010 self.eval_bridge(&code, params.webview_label.as_deref())
1011 .await
1012 }
1013 }
1014 }
1015
1016 #[tool(
1017 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.",
1018 annotations(
1019 read_only_hint = false,
1020 destructive_hint = false,
1021 idempotent_hint = false,
1022 open_world_hint = false
1023 )
1024 )]
1025 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1026 match params.action {
1027 InputAction::Fill => {
1028 if !self.state.privacy.is_tool_enabled("fill") {
1029 return tool_disabled("fill");
1030 }
1031 let Some(ref_id) = ¶ms.ref_id else {
1032 return missing_param("ref_id", "fill");
1033 };
1034 let Some(value) = ¶ms.value else {
1035 return missing_param("value", "fill");
1036 };
1037 let code = format!(
1038 "return window.__VICTAURI__?.fill({}, {})",
1039 js_string(ref_id),
1040 js_string(value)
1041 );
1042 self.eval_bridge(&code, params.webview_label.as_deref())
1043 .await
1044 }
1045 InputAction::TypeText => {
1046 if !self.state.privacy.is_tool_enabled("type_text") {
1047 return tool_disabled("type_text");
1048 }
1049 let Some(ref_id) = ¶ms.ref_id else {
1050 return missing_param("ref_id", "type_text");
1051 };
1052 let Some(text) = ¶ms.text else {
1053 return missing_param("text", "type_text");
1054 };
1055 let code = format!(
1056 "return window.__VICTAURI__?.type({}, {})",
1057 js_string(ref_id),
1058 js_string(text)
1059 );
1060 self.eval_bridge(&code, params.webview_label.as_deref())
1061 .await
1062 }
1063 InputAction::PressKey => {
1064 if !self.state.privacy.is_tool_enabled("input.press_key") {
1065 return tool_disabled("input.press_key");
1066 }
1067 let Some(key) = ¶ms.key else {
1068 return missing_param("key", "press_key");
1069 };
1070 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1071 self.eval_bridge(&code, params.webview_label.as_deref())
1072 .await
1073 }
1074 }
1075 }
1076
1077 #[tool(
1078 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.",
1079 annotations(
1080 read_only_hint = false,
1081 destructive_hint = false,
1082 idempotent_hint = true,
1083 open_world_hint = false
1084 )
1085 )]
1086 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1087 self.track_tool_call();
1088 match params.action {
1089 WindowAction::GetState => {
1090 let states = self.bridge.get_window_states(params.label.as_deref());
1091 json_result(&states)
1092 }
1093 WindowAction::List => {
1094 let labels = self.bridge.list_window_labels();
1095 json_result(&labels)
1096 }
1097 WindowAction::Manage => {
1098 if !self.state.privacy.is_tool_enabled("window.manage") {
1099 return tool_disabled("window.manage");
1100 }
1101 let Some(manage_action) = ¶ms.manage_action else {
1102 return missing_param("manage_action", "manage");
1103 };
1104 match self
1105 .bridge
1106 .manage_window(params.label.as_deref(), manage_action.as_str())
1107 {
1108 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1109 Err(e) => tool_error(e),
1110 }
1111 }
1112 WindowAction::Resize => {
1113 if !self.state.privacy.is_tool_enabled("window.resize") {
1114 return tool_disabled("window.resize");
1115 }
1116 let Some(width) = params.width else {
1117 return missing_param("width", "resize");
1118 };
1119 let Some(height) = params.height else {
1120 return missing_param("height", "resize");
1121 };
1122 match self
1123 .bridge
1124 .resize_window(params.label.as_deref(), width, height)
1125 {
1126 Ok(()) => {
1127 let result =
1128 serde_json::json!({"ok": true, "width": width, "height": height});
1129 CallToolResult::success(vec![Content::text(result.to_string())])
1130 }
1131 Err(e) => tool_error(e),
1132 }
1133 }
1134 WindowAction::MoveTo => {
1135 if !self.state.privacy.is_tool_enabled("window.move_to") {
1136 return tool_disabled("window.move_to");
1137 }
1138 let Some(x) = params.x else {
1139 return missing_param("x", "move_to");
1140 };
1141 let Some(y) = params.y else {
1142 return missing_param("y", "move_to");
1143 };
1144 match self.bridge.move_window(params.label.as_deref(), x, y) {
1145 Ok(()) => {
1146 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1147 CallToolResult::success(vec![Content::text(result.to_string())])
1148 }
1149 Err(e) => tool_error(e),
1150 }
1151 }
1152 WindowAction::SetTitle => {
1153 if !self.state.privacy.is_tool_enabled("window.set_title") {
1154 return tool_disabled("window.set_title");
1155 }
1156 let Some(title) = ¶ms.title else {
1157 return missing_param("title", "set_title");
1158 };
1159 match self.bridge.set_window_title(params.label.as_deref(), title) {
1160 Ok(()) => {
1161 let result = serde_json::json!({"ok": true, "title": title});
1162 CallToolResult::success(vec![Content::text(result.to_string())])
1163 }
1164 Err(e) => tool_error(e),
1165 }
1166 }
1167 }
1168 }
1169
1170 #[tool(
1171 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1172 annotations(
1173 read_only_hint = false,
1174 destructive_hint = true,
1175 idempotent_hint = false,
1176 open_world_hint = false
1177 )
1178 )]
1179 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1180 match params.action {
1181 StorageAction::Get => {
1182 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1183 StorageType::Session => "getSessionStorage",
1184 StorageType::Local => "getLocalStorage",
1185 };
1186 let key_arg = params
1187 .key
1188 .as_ref()
1189 .map(|k| js_string(k))
1190 .unwrap_or_default();
1191 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1192 self.eval_bridge(&code, params.webview_label.as_deref())
1193 .await
1194 }
1195 StorageAction::Set => {
1196 if !self.state.privacy.is_tool_enabled("set_storage") {
1197 return tool_disabled("set_storage");
1198 }
1199 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1200 StorageType::Session => "setSessionStorage",
1201 StorageType::Local => "setLocalStorage",
1202 };
1203 let Some(key) = ¶ms.key else {
1204 return missing_param("key", "set");
1205 };
1206 let value = params
1207 .value
1208 .as_ref()
1209 .cloned()
1210 .unwrap_or(serde_json::Value::Null);
1211 let value_json =
1212 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1213 let code = format!(
1214 "return window.__VICTAURI__?.{method}({}, {value_json})",
1215 js_string(key)
1216 );
1217 self.eval_bridge(&code, params.webview_label.as_deref())
1218 .await
1219 }
1220 StorageAction::Delete => {
1221 if !self.state.privacy.is_tool_enabled("delete_storage") {
1222 return tool_disabled("delete_storage");
1223 }
1224 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1225 StorageType::Session => "deleteSessionStorage",
1226 StorageType::Local => "deleteLocalStorage",
1227 };
1228 let Some(key) = ¶ms.key else {
1229 return missing_param("key", "delete");
1230 };
1231 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1232 self.eval_bridge(&code, params.webview_label.as_deref())
1233 .await
1234 }
1235 StorageAction::GetCookies => {
1236 self.eval_bridge(
1237 "return window.__VICTAURI__?.getCookies()",
1238 params.webview_label.as_deref(),
1239 )
1240 .await
1241 }
1242 }
1243 }
1244
1245 #[tool(
1246 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.",
1247 annotations(
1248 read_only_hint = false,
1249 destructive_hint = false,
1250 idempotent_hint = false,
1251 open_world_hint = false
1252 )
1253 )]
1254 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1255 match params.action {
1256 NavigateAction::GoTo => {
1257 if !self.state.privacy.is_tool_enabled("navigate") {
1258 return tool_disabled("navigate");
1259 }
1260 let Some(url) = ¶ms.url else {
1261 return missing_param("url", "go_to");
1262 };
1263 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1264 return tool_error(e);
1265 }
1266 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1267 self.eval_bridge(&code, params.webview_label.as_deref())
1268 .await
1269 }
1270 NavigateAction::GoBack => {
1271 self.eval_bridge(
1272 "return window.__VICTAURI__?.navigateBack()",
1273 params.webview_label.as_deref(),
1274 )
1275 .await
1276 }
1277 NavigateAction::GetHistory => {
1278 self.eval_bridge(
1279 "return window.__VICTAURI__?.getNavigationLog()",
1280 params.webview_label.as_deref(),
1281 )
1282 .await
1283 }
1284 NavigateAction::SetDialogResponse => {
1285 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1286 return tool_disabled("set_dialog_response");
1287 }
1288 let Some(dialog_type) = params.dialog_type else {
1289 return missing_param("dialog_type", "set_dialog_response");
1290 };
1291 let Some(dialog_action) = params.dialog_action else {
1292 return missing_param("dialog_action", "set_dialog_response");
1293 };
1294 let text_arg = params
1295 .text
1296 .as_ref()
1297 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1298 let code = format!(
1299 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1300 js_string(dialog_type.as_str()),
1301 js_string(dialog_action.as_str())
1302 );
1303 self.eval_bridge(&code, params.webview_label.as_deref())
1304 .await
1305 }
1306 NavigateAction::GetDialogLog => {
1307 self.eval_bridge(
1308 "return window.__VICTAURI__?.getDialogLog()",
1309 params.webview_label.as_deref(),
1310 )
1311 .await
1312 }
1313 }
1314 }
1315
1316 #[tool(
1317 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).",
1318 annotations(
1319 read_only_hint = false,
1320 destructive_hint = false,
1321 idempotent_hint = false,
1322 open_world_hint = false
1323 )
1324 )]
1325 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1326 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1327 self.track_tool_call();
1328 if !self.state.privacy.is_tool_enabled("recording") {
1329 return tool_disabled("recording");
1330 }
1331 match params.action {
1332 RecordingAction::Start => {
1333 let session_id = params
1334 .session_id
1335 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1336 match self.state.recorder.start(session_id.clone()) {
1337 Ok(()) => {
1338 let result = serde_json::json!({
1339 "started": true,
1340 "session_id": session_id,
1341 });
1342 CallToolResult::success(vec![Content::text(result.to_string())])
1343 }
1344 Err(e) => tool_error(e.to_string()),
1345 }
1346 }
1347 RecordingAction::Stop => match self.state.recorder.stop() {
1348 Some(session) => json_result(&session),
1349 None => tool_error("no recording is active"),
1350 },
1351 RecordingAction::Checkpoint => {
1352 let Some(id) = params.checkpoint_id else {
1353 return missing_param("checkpoint_id", "checkpoint");
1354 };
1355 let state = params.state.unwrap_or(serde_json::Value::Null);
1356 match self
1357 .state
1358 .recorder
1359 .checkpoint(id.clone(), params.checkpoint_label, state)
1360 {
1361 Ok(()) => {
1362 let result = serde_json::json!({
1363 "created": true,
1364 "checkpoint_id": id,
1365 "event_index": self.state.recorder.event_count(),
1366 });
1367 CallToolResult::success(vec![Content::text(result.to_string())])
1368 }
1369 Err(e) => tool_error(e.to_string()),
1370 }
1371 }
1372 RecordingAction::ListCheckpoints => {
1373 let checkpoints = self.state.recorder.get_checkpoints();
1374 json_result(&checkpoints)
1375 }
1376 RecordingAction::GetEvents => {
1377 let events = self
1378 .state
1379 .recorder
1380 .events_since(params.since_index.unwrap_or(0));
1381 json_result(&events)
1382 }
1383 RecordingAction::EventsBetween => {
1384 let Some(from) = ¶ms.from else {
1385 return missing_param("from", "events_between");
1386 };
1387 let Some(to) = ¶ms.to else {
1388 return missing_param("to", "events_between");
1389 };
1390 match self.state.recorder.events_between_checkpoints(from, to) {
1391 Ok(events) => json_result(&events),
1392 Err(e) => tool_error(e.to_string()),
1393 }
1394 }
1395 RecordingAction::GetReplay => {
1396 let calls = self.state.recorder.ipc_replay_sequence();
1397 json_result(&calls)
1398 }
1399 RecordingAction::Export => match self.state.recorder.export() {
1400 Some(s) => {
1401 let json = serde_json::to_string_pretty(&s)
1402 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1403 CallToolResult::success(vec![Content::text(json)])
1404 }
1405 None => tool_error("no recording is active — start one first"),
1406 },
1407 RecordingAction::Import => {
1408 let Some(session_json) = ¶ms.session_json else {
1409 return missing_param("session_json", "import");
1410 };
1411 if session_json.len() > MAX_SESSION_JSON {
1412 return tool_error("session JSON exceeds maximum size (10 MB)");
1413 }
1414 let session: victauri_core::RecordedSession =
1415 match serde_json::from_str(session_json) {
1416 Ok(s) => s,
1417 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1418 };
1419
1420 let result = serde_json::json!({
1421 "imported": true,
1422 "session_id": session.id,
1423 "event_count": session.events.len(),
1424 "checkpoint_count": session.checkpoints.len(),
1425 "started_at": session.started_at.to_rfc3339(),
1426 });
1427 self.state.recorder.import(session);
1428 CallToolResult::success(vec![Content::text(result.to_string())])
1429 }
1430 }
1431 }
1432
1433 #[tool(
1434 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).",
1435 annotations(
1436 read_only_hint = true,
1437 destructive_hint = false,
1438 idempotent_hint = true,
1439 open_world_hint = false
1440 )
1441 )]
1442 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1443 match params.action {
1444 InspectAction::GetStyles => {
1445 let Some(ref_id) = ¶ms.ref_id else {
1446 return missing_param("ref_id", "get_styles");
1447 };
1448 let props_arg = match ¶ms.properties {
1449 Some(props) => {
1450 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1451 format!("[{}]", arr.join(","))
1452 }
1453 None => "null".to_string(),
1454 };
1455 let code = format!(
1456 "return window.__VICTAURI__?.getStyles({}, {})",
1457 js_string(ref_id),
1458 props_arg
1459 );
1460 self.eval_bridge(&code, params.webview_label.as_deref())
1461 .await
1462 }
1463 InspectAction::GetBoundingBoxes => {
1464 let Some(ref_ids) = ¶ms.ref_ids else {
1465 return missing_param("ref_ids", "get_bounding_boxes");
1466 };
1467 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1468 let code = format!(
1469 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1470 refs.join(",")
1471 );
1472 self.eval_bridge(&code, params.webview_label.as_deref())
1473 .await
1474 }
1475 InspectAction::Highlight => {
1476 let Some(ref_id) = ¶ms.ref_id else {
1477 return missing_param("ref_id", "highlight");
1478 };
1479 let color_arg = match ¶ms.color {
1480 Some(c) => match sanitize_css_color(c) {
1481 Ok(safe) => format!("\"{safe}\""),
1482 Err(e) => return tool_error(e),
1483 },
1484 None => "null".to_string(),
1485 };
1486 let label_arg = match ¶ms.label {
1487 Some(l) => js_string(l),
1488 None => "null".to_string(),
1489 };
1490 let code = format!(
1491 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1492 js_string(ref_id),
1493 color_arg,
1494 label_arg
1495 );
1496 self.eval_bridge(&code, params.webview_label.as_deref())
1497 .await
1498 }
1499 InspectAction::ClearHighlights => {
1500 self.eval_bridge(
1501 "return window.__VICTAURI__?.clearHighlights()",
1502 params.webview_label.as_deref(),
1503 )
1504 .await
1505 }
1506 InspectAction::AuditAccessibility => {
1507 self.eval_bridge(
1508 "return window.__VICTAURI__?.auditAccessibility()",
1509 params.webview_label.as_deref(),
1510 )
1511 .await
1512 }
1513 InspectAction::GetPerformance => {
1514 self.eval_bridge(
1515 "return window.__VICTAURI__?.getPerformanceMetrics()",
1516 params.webview_label.as_deref(),
1517 )
1518 .await
1519 }
1520 }
1521 }
1522
1523 #[tool(
1524 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1525 annotations(
1526 read_only_hint = false,
1527 destructive_hint = false,
1528 idempotent_hint = true,
1529 open_world_hint = false
1530 )
1531 )]
1532 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1533 match params.action {
1534 CssAction::Inject => {
1535 if !self.state.privacy.is_tool_enabled("inject_css") {
1536 return tool_disabled("inject_css");
1537 }
1538 let Some(css) = ¶ms.css else {
1539 return missing_param("css", "inject");
1540 };
1541 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1542 self.eval_bridge(&code, params.webview_label.as_deref())
1543 .await
1544 }
1545 CssAction::Remove => {
1546 if !self.state.privacy.is_tool_enabled("css.remove") {
1547 return tool_disabled("css.remove");
1548 }
1549 self.eval_bridge(
1550 "return window.__VICTAURI__?.removeInjectedCss()",
1551 params.webview_label.as_deref(),
1552 )
1553 .await
1554 }
1555 }
1556 }
1557
1558 #[tool(
1559 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).",
1560 annotations(
1561 read_only_hint = true,
1562 destructive_hint = false,
1563 idempotent_hint = true,
1564 open_world_hint = false
1565 )
1566 )]
1567 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1568 match params.action {
1569 LogsAction::Console => {
1570 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1571 let code = if since_arg.is_empty() {
1572 "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1573 } else {
1574 format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1575 };
1576 self.eval_bridge(&code, params.webview_label.as_deref())
1577 .await
1578 }
1579 LogsAction::Network => {
1580 let filter_arg = params
1581 .filter
1582 .as_ref()
1583 .map_or_else(|| "null".to_string(), |f| js_string(f));
1584 let limit_arg = params
1585 .limit
1586 .map_or_else(|| "null".to_string(), |l| l.to_string());
1587 let code =
1588 format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1589 self.eval_bridge(&code, params.webview_label.as_deref())
1590 .await
1591 }
1592 LogsAction::Ipc => {
1593 let wait = params.wait_for_capture.unwrap_or(false);
1594 let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1595 if wait {
1596 let limit_js = if limit_arg.is_empty() {
1597 "undefined".to_string()
1598 } else {
1599 limit_arg.clone()
1600 };
1601 let code = format!(
1602 r"return (async function() {{
1603 await window.__VICTAURI__.waitForIpcComplete(500);
1604 var log = window.__VICTAURI__.getIpcLog() || [];
1605 var lim = {limit_js};
1606 return (lim !== undefined) ? log.slice(-lim) : log;
1607 }})()"
1608 );
1609 let timeout = std::time::Duration::from_millis(5000);
1610 match self
1611 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1612 .await
1613 {
1614 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1615 Err(e) => tool_error(e),
1616 }
1617 } else {
1618 let code = if limit_arg.is_empty() {
1619 "return window.__VICTAURI__?.getIpcLog()".to_string()
1620 } else {
1621 format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1622 };
1623 self.eval_bridge(&code, params.webview_label.as_deref())
1624 .await
1625 }
1626 }
1627 LogsAction::Navigation => {
1628 self.eval_bridge(
1629 "return window.__VICTAURI__?.getNavigationLog()",
1630 params.webview_label.as_deref(),
1631 )
1632 .await
1633 }
1634 LogsAction::Dialogs => {
1635 self.eval_bridge(
1636 "return window.__VICTAURI__?.getDialogLog()",
1637 params.webview_label.as_deref(),
1638 )
1639 .await
1640 }
1641 LogsAction::Events => {
1642 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1643 let code = if since_arg.is_empty() {
1644 "return window.__VICTAURI__?.getEventStream()".to_string()
1645 } else {
1646 format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1647 };
1648 self.eval_bridge(&code, params.webview_label.as_deref())
1649 .await
1650 }
1651 LogsAction::SlowIpc => {
1652 let Some(threshold) = params.threshold_ms else {
1653 return missing_param("threshold_ms", "slow_ipc");
1654 };
1655 let limit = params.limit.unwrap_or(20);
1656 let code = format!(
1657 r"return (function() {{
1658 var log = window.__VICTAURI__?.getIpcLog() || [];
1659 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1660 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1661 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1662 }})()",
1663 );
1664 self.eval_bridge(&code, None).await
1665 }
1666 }
1667 }
1668}
1669
1670impl VictauriMcpHandler {
1671 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1673 Self {
1674 state,
1675 bridge,
1676 subscriptions: Arc::new(Mutex::new(HashSet::new())),
1677 bridge_checked: Arc::new(AtomicBool::new(false)),
1678 }
1679 }
1680
1681 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
1682 self.state.privacy.is_tool_enabled(name)
1683 }
1684
1685 pub(crate) async fn execute_tool(
1686 &self,
1687 name: &str,
1688 args: serde_json::Value,
1689 ) -> Result<CallToolResult, rest::ToolCallError> {
1690 if !self.state.privacy.is_tool_enabled(name) {
1691 return Ok(tool_disabled(name));
1692 }
1693 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1694 let start = std::time::Instant::now();
1695 tracing::debug!(tool = %name, "REST tool invocation started");
1696
1697 let result = match name {
1698 "eval_js" => {
1699 let p: EvalJsParams = Self::parse_args(args)?;
1700 self.eval_js(Parameters(p)).await
1701 }
1702 "dom_snapshot" => {
1703 let p: SnapshotParams = Self::parse_args(args)?;
1704 self.dom_snapshot(Parameters(p)).await
1705 }
1706 "find_elements" => {
1707 let p: FindElementsParams = Self::parse_args(args)?;
1708 self.find_elements(Parameters(p)).await
1709 }
1710 "invoke_command" => {
1711 let p: InvokeCommandParams = Self::parse_args(args)?;
1712 self.invoke_command(Parameters(p)).await
1713 }
1714 "screenshot" => {
1715 let p: ScreenshotParams = Self::parse_args(args)?;
1716 self.screenshot(Parameters(p)).await
1717 }
1718 "verify_state" => {
1719 let p: VerifyStateParams = Self::parse_args(args)?;
1720 self.verify_state(Parameters(p)).await
1721 }
1722 "detect_ghost_commands" => {
1723 let p: GhostCommandParams = Self::parse_args(args)?;
1724 self.detect_ghost_commands(Parameters(p)).await
1725 }
1726 "check_ipc_integrity" => {
1727 let p: IpcIntegrityParams = Self::parse_args(args)?;
1728 self.check_ipc_integrity(Parameters(p)).await
1729 }
1730 "wait_for" => {
1731 let p: WaitForParams = Self::parse_args(args)?;
1732 self.wait_for(Parameters(p)).await
1733 }
1734 "assert_semantic" => {
1735 let p: SemanticAssertParams = Self::parse_args(args)?;
1736 self.assert_semantic(Parameters(p)).await
1737 }
1738 "resolve_command" => {
1739 let p: ResolveCommandParams = Self::parse_args(args)?;
1740 self.resolve_command(Parameters(p)).await
1741 }
1742 "get_registry" => {
1743 let p: RegistryParams = Self::parse_args(args)?;
1744 self.get_registry(Parameters(p)).await
1745 }
1746 "get_memory_stats" => self.get_memory_stats().await,
1747 "get_plugin_info" => self.get_plugin_info().await,
1748 "get_diagnostics" => {
1749 let p: DiagnosticsParams = Self::parse_args(args)?;
1750 self.get_diagnostics(Parameters(p)).await
1751 }
1752 "app_info" => self.app_info().await,
1753 "list_app_dir" => {
1754 let p: ListAppDirParams = Self::parse_args(args)?;
1755 self.list_app_dir(Parameters(p)).await
1756 }
1757 "read_app_file" => {
1758 let p: ReadAppFileParams = Self::parse_args(args)?;
1759 self.read_app_file(Parameters(p)).await
1760 }
1761 #[cfg(feature = "sqlite")]
1762 "query_db" => {
1763 let p: QueryDbParams = Self::parse_args(args)?;
1764 self.query_db(Parameters(p)).await
1765 }
1766 "interact" => {
1767 let p: InteractParams = Self::parse_args(args)?;
1768 self.interact(Parameters(p)).await
1769 }
1770 "input" => {
1771 let p: InputParams = Self::parse_args(args)?;
1772 self.input(Parameters(p)).await
1773 }
1774 "window" => {
1775 let p: WindowParams = Self::parse_args(args)?;
1776 self.window(Parameters(p)).await
1777 }
1778 "storage" => {
1779 let p: StorageParams = Self::parse_args(args)?;
1780 self.storage(Parameters(p)).await
1781 }
1782 "navigate" => {
1783 let p: NavigateParams = Self::parse_args(args)?;
1784 self.navigate(Parameters(p)).await
1785 }
1786 "recording" => {
1787 let p: RecordingParams = Self::parse_args(args)?;
1788 self.recording(Parameters(p)).await
1789 }
1790 "inspect" => {
1791 let p: InspectParams = Self::parse_args(args)?;
1792 self.inspect(Parameters(p)).await
1793 }
1794 "css" => {
1795 let p: CssParams = Self::parse_args(args)?;
1796 self.css(Parameters(p)).await
1797 }
1798 "logs" => {
1799 let p: LogsParams = Self::parse_args(args)?;
1800 self.logs(Parameters(p)).await
1801 }
1802 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
1803 };
1804
1805 let elapsed = start.elapsed();
1806 tracing::debug!(
1807 tool = %name,
1808 elapsed_ms = elapsed.as_millis() as u64,
1809 "REST tool invocation completed"
1810 );
1811
1812 if self.state.privacy.redaction_enabled {
1813 Ok(Self::redact_result(result, &self.state.privacy))
1814 } else {
1815 Ok(result)
1816 }
1817 }
1818
1819 fn parse_args<T: serde::de::DeserializeOwned>(
1820 args: serde_json::Value,
1821 ) -> Result<T, rest::ToolCallError> {
1822 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
1823 }
1824
1825 fn redact_result(
1826 mut result: CallToolResult,
1827 privacy: &crate::privacy::PrivacyConfig,
1828 ) -> CallToolResult {
1829 for item in &mut result.content {
1830 if let RawContent::Text(ref mut tc) = item.raw {
1831 tc.text = privacy.redact_output(&tc.text);
1832 }
1833 }
1834 result
1835 }
1836
1837 fn track_tool_call(&self) {
1838 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1839 }
1840
1841 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
1842 match dir.unwrap_or(AppDir::Data) {
1843 AppDir::Data => self.bridge.app_data_dir(),
1844 AppDir::Config => self.bridge.app_config_dir(),
1845 AppDir::Log => self.bridge.app_log_dir(),
1846 AppDir::LocalData => self.bridge.app_local_data_dir(),
1847 }
1848 }
1849
1850 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
1851 let canon_base = std::fs::canonicalize(base)
1852 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1853 let canon_target = std::fs::canonicalize(target)
1854 .map_err(|e| format!("cannot resolve target path: {e}"))?;
1855 if !canon_target.starts_with(&canon_base) {
1856 return Err("path traversal not allowed".to_string());
1857 }
1858 Ok(())
1859 }
1860
1861 fn list_dir_recursive(
1862 dir: &std::path::Path,
1863 base: &std::path::Path,
1864 depth: u32,
1865 max_depth: u32,
1866 pattern: Option<&str>,
1867 entries: &mut Vec<serde_json::Value>,
1868 ) {
1869 let Ok(read_dir) = std::fs::read_dir(dir) else {
1870 return;
1871 };
1872 for entry in read_dir.flatten() {
1873 let path = entry.path();
1874 if path.is_symlink() {
1875 continue;
1876 }
1877 let name = entry.file_name().to_string_lossy().into_owned();
1878 let relative = path
1879 .strip_prefix(base)
1880 .unwrap_or(&path)
1881 .to_string_lossy()
1882 .into_owned();
1883
1884 if let Some(pat) = pattern
1885 && !Self::matches_glob(&name, pat)
1886 && !path.is_dir()
1887 {
1888 continue;
1889 }
1890
1891 let is_dir = path.is_dir();
1892 let meta = std::fs::metadata(&path).ok();
1893
1894 entries.push(serde_json::json!({
1895 "name": name,
1896 "path": relative,
1897 "is_dir": is_dir,
1898 "size": meta.as_ref().map(std::fs::Metadata::len),
1899 "modified": meta.as_ref()
1900 .and_then(|m| m.modified().ok())
1901 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
1902 .unwrap_or_default().as_secs()),
1903 }));
1904
1905 if is_dir && depth < max_depth {
1906 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
1907 }
1908 }
1909 }
1910
1911 fn matches_glob(name: &str, pattern: &str) -> bool {
1912 if pattern == "*" {
1913 return true;
1914 }
1915 if let Some(suffix) = pattern.strip_prefix("*.") {
1916 return name.ends_with(&format!(".{suffix}"));
1917 }
1918 if let Some(prefix) = pattern.strip_suffix("*") {
1919 return name.starts_with(prefix);
1920 }
1921 name == pattern
1922 }
1923
1924 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
1925 match self.eval_with_return(code, webview_label).await {
1926 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1927 Err(e) => tool_error(e),
1928 }
1929 }
1930
1931 async fn eval_with_return(
1932 &self,
1933 code: &str,
1934 webview_label: Option<&str>,
1935 ) -> Result<String, String> {
1936 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1937 .await
1938 }
1939
1940 async fn eval_with_return_timeout(
1941 &self,
1942 code: &str,
1943 webview_label: Option<&str>,
1944 timeout: std::time::Duration,
1945 ) -> Result<String, String> {
1946 self.track_tool_call();
1947 let id = uuid::Uuid::new_v4().to_string();
1948 let (tx, rx) = tokio::sync::oneshot::channel();
1949
1950 {
1951 let mut pending = self.state.pending_evals.lock().await;
1952 if pending.len() >= MAX_PENDING_EVALS {
1953 return Err(format!(
1954 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1955 ));
1956 }
1957 pending.insert(id.clone(), tx);
1958 }
1959
1960 let code = code.trim();
1964 let needs_return = !code.starts_with("return ")
1965 && !code.starts_with("return;")
1966 && !code.starts_with('{')
1967 && !code.starts_with("if ")
1968 && !code.starts_with("if(")
1969 && !code.starts_with("for ")
1970 && !code.starts_with("for(")
1971 && !code.starts_with("while ")
1972 && !code.starts_with("while(")
1973 && !code.starts_with("switch ")
1974 && !code.starts_with("try ")
1975 && !code.starts_with("const ")
1976 && !code.starts_with("let ")
1977 && !code.starts_with("var ")
1978 && !code.starts_with("function ")
1979 && !code.starts_with("class ")
1980 && !code.starts_with("throw ");
1981 let code = if needs_return {
1982 format!("return {code}")
1983 } else {
1984 code.to_string()
1985 };
1986
1987 let id_js = js_string(&id);
1988 let inject = format!(
1989 r"
1990 (async () => {{
1991 try {{
1992 const __result = await (async () => {{ {code} }})();
1993 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1994 id: {id_js},
1995 result: JSON.stringify(__result)
1996 }});
1997 }} catch (e) {{
1998 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1999 id: {id_js},
2000 result: JSON.stringify({{ __error: e.message }})
2001 }});
2002 }}
2003 }})();
2004 "
2005 );
2006
2007 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
2008 self.state.pending_evals.lock().await.remove(&id);
2009 return Err(format!("eval injection failed: {e}"));
2010 }
2011
2012 match tokio::time::timeout(timeout, rx).await {
2013 Ok(Ok(result)) => {
2014 self.check_bridge_version_once();
2015 Ok(result)
2016 }
2017 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
2018 Err(_) => {
2019 self.state.pending_evals.lock().await.remove(&id);
2020 Err(format!("eval timed out after {}s", timeout.as_secs()))
2021 }
2022 }
2023 }
2024
2025 fn check_bridge_version_once(&self) {
2026 if self.bridge_checked.swap(true, Ordering::Relaxed) {
2027 return;
2028 }
2029 let handler = self.clone();
2030 tokio::spawn(async move {
2031 match handler
2032 .eval_with_return_timeout(
2033 "window.__VICTAURI__?.version",
2034 None,
2035 std::time::Duration::from_secs(5),
2036 )
2037 .await
2038 {
2039 Ok(v) => {
2040 let v = v.trim_matches('"');
2041 if v == BRIDGE_VERSION {
2042 tracing::debug!("Bridge version verified: {v}");
2043 } else {
2044 tracing::warn!(
2045 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
2046 );
2047 }
2048 }
2049 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
2050 }
2051 });
2052 }
2053}
2054
2055const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection tool for Tauri applications. \
2056It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
2057(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
2058(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
2059\n\nBACKEND tools (direct Rust access, no webview needed): \
2060'app_info' (app config, directory paths, discovered databases, process info), \
2061'list_app_dir' (browse app data/config/log directories), \
2062'read_app_file' (read files from app directories), \
2063'query_db' (read-only SQLite queries with auto-discovery). \
2064\n\nWEBVIEW tools: \
2065'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
2066'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
2067'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
2068\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
2069\n\nCOMPOUND tools with an 'action' parameter: \
2070'window' (get_state, list, manage, resize, move_to, set_title), \
2071'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
2072set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
2073get_events, events_between, get_replay, export, import), \
2074'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
2075\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
2076get_memory_stats, get_plugin_info, get_diagnostics.";
2077
2078impl ServerHandler for VictauriMcpHandler {
2079 fn get_info(&self) -> ServerInfo {
2080 ServerInfo::new(
2081 ServerCapabilities::builder()
2082 .enable_tools()
2083 .enable_resources()
2084 .enable_resources_subscribe()
2085 .build(),
2086 )
2087 .with_instructions(SERVER_INSTRUCTIONS)
2088 }
2089
2090 async fn list_tools(
2091 &self,
2092 _request: Option<PaginatedRequestParams>,
2093 _context: RequestContext<RoleServer>,
2094 ) -> Result<ListToolsResult, ErrorData> {
2095 let all_tools = Self::tool_router().list_all();
2096 let filtered: Vec<Tool> = all_tools
2097 .into_iter()
2098 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
2099 .collect();
2100 Ok(ListToolsResult {
2101 tools: filtered,
2102 ..Default::default()
2103 })
2104 }
2105
2106 async fn call_tool(
2107 &self,
2108 request: CallToolRequestParams,
2109 context: RequestContext<RoleServer>,
2110 ) -> Result<CallToolResult, ErrorData> {
2111 let tool_name: String = request.name.as_ref().to_owned();
2112 if !self.state.privacy.is_tool_enabled(&tool_name) {
2113 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
2114 return Ok(tool_disabled(&tool_name));
2115 }
2116 self.state
2117 .tool_invocations
2118 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2119 let start = std::time::Instant::now();
2120 tracing::debug!(tool = %tool_name, "tool invocation started");
2121 let ctx = ToolCallContext::new(self, request, context);
2122 let result = Self::tool_router().call(ctx).await;
2123 let elapsed = start.elapsed();
2124 tracing::debug!(
2125 tool = %tool_name,
2126 elapsed_ms = elapsed.as_millis() as u64,
2127 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
2128 "tool invocation completed"
2129 );
2130
2131 if self.state.privacy.redaction_enabled {
2134 result.map(|mut r| {
2135 for item in &mut r.content {
2136 if let RawContent::Text(ref mut tc) = item.raw {
2137 tc.text = self.state.privacy.redact_output(&tc.text);
2138 }
2139 }
2140 r
2141 })
2142 } else {
2143 result
2144 }
2145 }
2146
2147 fn get_tool(&self, name: &str) -> Option<Tool> {
2148 if !self.state.privacy.is_tool_enabled(name) {
2149 return None;
2150 }
2151 Self::tool_router().get(name).cloned()
2152 }
2153
2154 async fn list_resources(
2155 &self,
2156 _request: Option<PaginatedRequestParams>,
2157 _context: RequestContext<RoleServer>,
2158 ) -> Result<ListResourcesResult, ErrorData> {
2159 Ok(ListResourcesResult {
2160 resources: vec![
2161 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
2162 .with_description(
2163 "Live IPC call log — all commands invoked between frontend and backend",
2164 )
2165 .with_mime_type("application/json")
2166 .no_annotation(),
2167 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
2168 .with_description(
2169 "Current state of all Tauri windows — position, size, visibility, focus",
2170 )
2171 .with_mime_type("application/json")
2172 .no_annotation(),
2173 RawResource::new(RESOURCE_URI_STATE, "state")
2174 .with_description(
2175 "Victauri plugin state — event count, registered commands, memory stats",
2176 )
2177 .with_mime_type("application/json")
2178 .no_annotation(),
2179 ],
2180 ..Default::default()
2181 })
2182 }
2183
2184 async fn read_resource(
2185 &self,
2186 request: ReadResourceRequestParams,
2187 _context: RequestContext<RoleServer>,
2188 ) -> Result<ReadResourceResult, ErrorData> {
2189 let uri = &request.uri;
2190 let json = match uri.as_str() {
2191 RESOURCE_URI_IPC_LOG => {
2192 if let Ok(json) = self
2193 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
2194 .await
2195 {
2196 json
2197 } else {
2198 let calls = self.state.event_log.ipc_calls();
2199 serde_json::to_string_pretty(&calls)
2200 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2201 }
2202 }
2203 RESOURCE_URI_WINDOWS => {
2204 let states = self.bridge.get_window_states(None);
2205 serde_json::to_string_pretty(&states)
2206 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2207 }
2208 RESOURCE_URI_STATE => {
2209 let state_json = serde_json::json!({
2210 "events_captured": self.state.event_log.len(),
2211 "commands_registered": self.state.registry.count(),
2212 "memory": crate::memory::current_stats(),
2213 "port": self.state.port.load(Ordering::Relaxed),
2214 });
2215 serde_json::to_string_pretty(&state_json)
2216 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2217 }
2218 _ => {
2219 return Err(ErrorData::resource_not_found(
2220 format!("unknown resource: {uri}"),
2221 None,
2222 ));
2223 }
2224 };
2225
2226 let json = if self.state.privacy.redaction_enabled {
2227 self.state.privacy.redact_output(&json)
2228 } else {
2229 json
2230 };
2231
2232 Ok(ReadResourceResult::new(vec![ResourceContents::text(
2233 json, uri,
2234 )]))
2235 }
2236
2237 async fn subscribe(
2238 &self,
2239 request: SubscribeRequestParams,
2240 _context: RequestContext<RoleServer>,
2241 ) -> Result<(), ErrorData> {
2242 let uri = &request.uri;
2243 match uri.as_str() {
2244 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
2245 self.subscriptions.lock().await.insert(uri.clone());
2246 tracing::info!("Client subscribed to resource: {uri}");
2247 Ok(())
2248 }
2249 _ => Err(ErrorData::resource_not_found(
2250 format!("unknown resource: {uri}"),
2251 None,
2252 )),
2253 }
2254 }
2255
2256 async fn unsubscribe(
2257 &self,
2258 request: UnsubscribeRequestParams,
2259 _context: RequestContext<RoleServer>,
2260 ) -> Result<(), ErrorData> {
2261 self.subscriptions.lock().await.remove(&request.uri);
2262 tracing::info!("Client unsubscribed from resource: {}", request.uri);
2263 Ok(())
2264 }
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269 use super::*;
2270
2271 #[test]
2272 fn js_string_simple() {
2273 assert_eq!(js_string("hello"), "\"hello\"");
2274 }
2275
2276 #[test]
2277 fn js_string_single_quotes() {
2278 let result = js_string("it's a test");
2279 assert!(result.contains("it's a test"));
2280 }
2281
2282 #[test]
2283 fn js_string_double_quotes() {
2284 let result = js_string(r#"say "hello""#);
2285 assert!(result.contains(r#"\""#));
2286 }
2287
2288 #[test]
2289 fn js_string_backslashes() {
2290 let result = js_string(r"path\to\file");
2291 assert!(result.contains(r"\\"));
2292 }
2293
2294 #[test]
2295 fn js_string_newlines_and_tabs() {
2296 let result = js_string("line1\nline2\ttab");
2297 assert!(result.contains(r"\n"));
2298 assert!(result.contains(r"\t"));
2299 assert!(!result.contains('\n'));
2300 }
2301
2302 #[test]
2303 fn js_string_null_bytes() {
2304 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
2305 let result = js_string(&input);
2306 assert!(result.contains("\\u0000"));
2308 assert!(!result.contains('\0'));
2309 }
2310
2311 #[test]
2312 fn js_string_template_literal_injection() {
2313 let result = js_string("`${alert(1)}`");
2314 assert!(result.starts_with('"'));
2317 assert!(result.ends_with('"'));
2318 }
2319
2320 #[test]
2321 fn js_string_unicode_separators() {
2322 let result = js_string("a\u{2028}b\u{2029}c");
2327 let decoded: String = serde_json::from_str(&result).unwrap();
2329 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
2330 }
2331
2332 #[test]
2333 fn js_string_empty() {
2334 assert_eq!(js_string(""), "\"\"");
2335 }
2336
2337 #[test]
2338 fn js_string_html_script_close() {
2339 let result = js_string("</script><img onerror=alert(1)>");
2341 assert!(result.starts_with('"'));
2342 let decoded: String = serde_json::from_str(&result).unwrap();
2344 assert_eq!(decoded, "</script><img onerror=alert(1)>");
2345 }
2346
2347 #[test]
2348 fn js_string_very_long() {
2349 let long = "a".repeat(100_000);
2350 let result = js_string(&long);
2351 assert!(result.len() >= 100_002); }
2353
2354 #[test]
2357 fn url_allows_http() {
2358 assert!(validate_url("http://example.com", false).is_ok());
2359 }
2360
2361 #[test]
2362 fn url_allows_https() {
2363 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
2364 }
2365
2366 #[test]
2367 fn url_allows_http_localhost() {
2368 assert!(validate_url("http://localhost:3000", false).is_ok());
2369 }
2370
2371 #[test]
2372 fn url_blocks_file_by_default() {
2373 let err = validate_url("file:///etc/passwd", false).unwrap_err();
2374 assert!(err.contains("file"), "error should mention the file scheme");
2375 }
2376
2377 #[test]
2378 fn url_allows_file_when_opted_in() {
2379 assert!(validate_url("file:///tmp/test.html", true).is_ok());
2380 }
2381
2382 #[test]
2383 fn url_blocks_javascript() {
2384 assert!(validate_url("javascript:alert(1)", false).is_err());
2385 }
2386
2387 #[test]
2388 fn url_blocks_javascript_case_insensitive() {
2389 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
2390 }
2391
2392 #[test]
2393 fn url_blocks_data_scheme() {
2394 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
2395 }
2396
2397 #[test]
2398 fn url_blocks_vbscript() {
2399 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
2400 }
2401
2402 #[test]
2403 fn url_rejects_invalid() {
2404 assert!(validate_url("not a url at all", false).is_err());
2405 }
2406
2407 #[test]
2408 fn url_strips_control_chars() {
2409 let input = format!("http://example{}com", '\0');
2411 assert!(validate_url(&input, false).is_ok());
2412 }
2413
2414 #[test]
2417 fn css_color_valid_hex() {
2418 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
2419 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
2420 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
2421 }
2422
2423 #[test]
2424 fn css_color_valid_rgb() {
2425 assert_eq!(
2426 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
2427 "rgb(255, 0, 0)"
2428 );
2429 assert_eq!(
2430 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
2431 "rgba(0, 0, 0, 0.5)"
2432 );
2433 }
2434
2435 #[test]
2436 fn css_color_valid_named() {
2437 assert_eq!(sanitize_css_color("red").unwrap(), "red");
2438 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
2439 }
2440
2441 #[test]
2442 fn css_color_valid_hsl() {
2443 assert_eq!(
2444 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
2445 "hsl(120, 50%, 50%)"
2446 );
2447 }
2448
2449 #[test]
2450 fn css_color_rejects_too_long() {
2451 let long = "a".repeat(101);
2452 assert!(sanitize_css_color(&long).is_err());
2453 }
2454
2455 #[test]
2456 fn css_color_rejects_backslash_escapes() {
2457 assert!(sanitize_css_color(r"red\00").is_err());
2458 assert!(sanitize_css_color(r"\72\65\64").is_err());
2459 }
2460
2461 #[test]
2462 fn css_color_rejects_url_injection() {
2463 assert!(sanitize_css_color("url(http://evil.com)").is_err());
2464 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
2465 }
2466
2467 #[test]
2468 fn css_color_rejects_expression_injection() {
2469 assert!(sanitize_css_color("expression(alert(1))").is_err());
2470 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
2471 }
2472
2473 #[test]
2474 fn css_color_rejects_import() {
2475 assert!(sanitize_css_color("@import url(evil.css)").is_err());
2476 }
2477
2478 #[test]
2479 fn css_color_rejects_semicolons_and_braces() {
2480 assert!(sanitize_css_color("red; background: url(evil)").is_err());
2481 assert!(sanitize_css_color("red} body { color: blue").is_err());
2482 }
2483
2484 #[test]
2485 fn css_color_rejects_special_chars() {
2486 assert!(sanitize_css_color("red<script>").is_err());
2487 assert!(sanitize_css_color("red\"onload=alert").is_err());
2488 assert!(sanitize_css_color("red'onclick=alert").is_err());
2489 }
2490
2491 #[test]
2492 fn css_color_trims_whitespace() {
2493 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
2494 }
2495
2496 #[test]
2497 fn css_color_empty_string() {
2498 assert_eq!(sanitize_css_color("").unwrap(), "");
2499 }
2500}