1mod backend_params;
2mod compound_params;
3mod helpers;
4mod introspection_params;
5mod other_params;
6mod rest;
7mod server;
8mod verification_params;
9mod webview_params;
10mod window_params;
11
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use rmcp::handler::server::tool::ToolCallContext;
17use rmcp::handler::server::wrapper::Parameters;
18use rmcp::model::{
19 AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
20 ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
21 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
22 Tool, UnsubscribeRequestParams,
23};
24use rmcp::service::RequestContext;
25use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
26use tokio::sync::Mutex;
27
28use crate::VictauriState;
29use crate::bridge::WebviewBridge;
30
31use helpers::{
32 js_string, json_result, missing_param, sanitize_css_color, tool_disabled, tool_error,
33 validate_url,
34};
35
36pub use backend_params::*;
37pub use compound_params::*;
38pub use introspection_params::*;
39pub use other_params::{
40 DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
41 WaitCondition, WaitForParams,
42};
43pub use server::*;
44pub use verification_params::*;
45pub use webview_params::*;
46pub use window_params::*;
47
48pub(crate) const MAX_PENDING_EVALS: usize = 100;
53
54fn chrono_now() -> String {
55 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
56}
57
58const MAX_EVAL_CODE_LEN: usize = 1_000_000;
60
61const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
64
65const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
66const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
67const RESOURCE_URI_STATE: &str = "victauri://state";
68
69const BRIDGE_VERSION: &str = "0.5.0";
70
71const SAFE_ENV_PREFIXES: &[&str] = &[
72 "HOME",
73 "USER",
74 "LANG",
75 "LC_",
76 "TERM",
77 "SHELL",
78 "DISPLAY",
79 "XDG_",
80 "TAURI_",
81 "VICTAURI_",
82 "NODE_ENV",
83 "OS",
84 "HOSTNAME",
85 "PWD",
86 "SHLVL",
87 "LOGNAME",
88];
89
90#[derive(Clone)]
92pub struct VictauriMcpHandler {
93 state: Arc<VictauriState>,
94 bridge: Arc<dyn WebviewBridge>,
95 subscriptions: Arc<Mutex<HashSet<String>>>,
96 bridge_checked: Arc<AtomicBool>,
97 probed_labels: Arc<Mutex<HashSet<String>>>,
98}
99
100#[tool_router]
101impl VictauriMcpHandler {
102 #[tool(
105 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
106 annotations(
107 read_only_hint = false,
108 destructive_hint = true,
109 idempotent_hint = false,
110 open_world_hint = false
111 )
112 )]
113 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
114 if !self.state.privacy.is_tool_enabled("eval_js") {
115 return tool_disabled("eval_js");
116 }
117 if params.code.len() > MAX_EVAL_CODE_LEN {
118 return tool_error("code exceeds maximum length (1 MB)");
119 }
120 match self
121 .eval_with_return(¶ms.code, params.webview_label.as_deref())
122 .await
123 {
124 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
125 Err(e) => tool_error(e),
126 }
127 }
128
129 #[tool(
130 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).",
131 annotations(
132 read_only_hint = true,
133 destructive_hint = false,
134 idempotent_hint = true,
135 open_world_hint = false
136 )
137 )]
138 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
139 let format = params.format.unwrap_or(SnapshotFormat::Compact);
140 let format_str = match format {
141 SnapshotFormat::Compact => "compact",
142 SnapshotFormat::Json => "json",
143 };
144 let code = format!(
145 "return window.__VICTAURI__?.snapshot({})",
146 js_string(format_str)
147 );
148 self.eval_bridge(&code, params.webview_label.as_deref())
149 .await
150 }
151
152 #[tool(
153 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.",
154 annotations(
155 read_only_hint = true,
156 destructive_hint = false,
157 idempotent_hint = true,
158 open_world_hint = false
159 )
160 )]
161 async fn find_elements(
162 &self,
163 Parameters(params): Parameters<FindElementsParams>,
164 ) -> CallToolResult {
165 let mut parts: Vec<String> = Vec::new();
166 if let Some(t) = ¶ms.text {
167 parts.push(format!("text: {}", js_string(t)));
168 }
169 if let Some(r) = ¶ms.role {
170 parts.push(format!("role: {}", js_string(r)));
171 }
172 if let Some(tid) = ¶ms.test_id {
173 parts.push(format!("test_id: {}", js_string(tid)));
174 }
175 if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
176 parts.push(format!("css: {}", js_string(c)));
177 }
178 if let Some(n) = ¶ms.name {
179 parts.push(format!("name: {}", js_string(n)));
180 }
181 if let Some(max) = params.max_results {
182 parts.push(format!("max_results: {max}"));
183 }
184 if let Some(t) = ¶ms.tag {
185 parts.push(format!("tag: {}", js_string(t)));
186 }
187 if let Some(p) = ¶ms.placeholder {
188 parts.push(format!("placeholder: {}", js_string(p)));
189 }
190 if let Some(a) = ¶ms.alt {
191 parts.push(format!("alt: {}", js_string(a)));
192 }
193 if let Some(ta) = ¶ms.title_attr {
194 parts.push(format!("title_attr: {}", js_string(ta)));
195 }
196 if let Some(l) = ¶ms.label {
197 parts.push(format!("label: {}", js_string(l)));
198 }
199 if let Some(true) = params.exact {
200 parts.push("exact: true".to_string());
201 }
202 if let Some(e) = params.enabled {
203 parts.push(format!("enabled: {e}"));
204 }
205 let code = format!(
206 "return window.__VICTAURI__?.findElements({{ {} }})",
207 parts.join(", ")
208 );
209 match self
210 .eval_with_return(&code, params.webview_label.as_deref())
211 .await
212 {
213 Ok(result) => {
214 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
215 && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
216 {
217 return tool_error(err);
218 }
219 CallToolResult::success(vec![Content::text(result)])
220 }
221 Err(e) => tool_error(e),
222 }
223 }
224
225 #[tool(
226 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.",
227 annotations(
228 read_only_hint = false,
229 destructive_hint = true,
230 idempotent_hint = false,
231 open_world_hint = false
232 )
233 )]
234 async fn invoke_command(
235 &self,
236 Parameters(params): Parameters<InvokeCommandParams>,
237 ) -> CallToolResult {
238 if !self.state.privacy.is_invoke_allowed(¶ms.command) {
239 return tool_disabled("invoke_command");
240 }
241 if !self.state.privacy.is_command_allowed(¶ms.command) {
242 return tool_error(format!(
243 "command '{}' is blocked by privacy configuration",
244 params.command
245 ));
246 }
247
248 if let Some(fault) = self.state.fault_registry.check_and_trigger(¶ms.command) {
250 match fault {
251 crate::introspection::FaultType::Delay { delay_ms } => {
252 tracing::info!(
253 command = %params.command,
254 delay_ms = delay_ms,
255 "fault injection: delaying command"
256 );
257 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
258 }
260 crate::introspection::FaultType::Error { ref message } => {
261 tracing::info!(
262 command = %params.command,
263 "fault injection: returning error"
264 );
265 return tool_error(format!(
266 "[FAULT INJECTED] command '{}': {message}",
267 params.command
268 ));
269 }
270 crate::introspection::FaultType::Drop => {
271 tracing::info!(
272 command = %params.command,
273 "fault injection: dropping response"
274 );
275 return CallToolResult::success(vec![Content::text("{}")]);
276 }
277 crate::introspection::FaultType::Corrupt => {
278 tracing::info!(
279 command = %params.command,
280 "fault injection: corrupting response"
281 );
282 let args_json = params.args.unwrap_or(serde_json::json!({}));
284 let args_str =
285 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
286 let code = format!(
287 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
288 js_string(¶ms.command)
289 );
290 if let Ok(result) = self
291 .eval_with_return(&code, params.webview_label.as_deref())
292 .await
293 {
294 let corrupted = format!(
295 "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
296 result.len()
297 );
298 return CallToolResult::success(vec![Content::text(corrupted)]);
299 }
300 return CallToolResult::success(vec![Content::text(
301 "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
302 )]);
303 }
304 }
305 }
306
307 let start = std::time::Instant::now();
309 let args_json = params.args.unwrap_or(serde_json::json!({}));
310 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
311 let code = format!(
312 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
313 js_string(¶ms.command)
314 );
315 let result = self
316 .eval_with_return(&code, params.webview_label.as_deref())
317 .await;
318 let elapsed = start.elapsed();
319 self.state.command_timings.record(¶ms.command, elapsed);
320
321 match result {
322 Ok(result) => {
323 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
324 && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
325 {
326 return tool_error(format!(
327 "command '{}' returned error: {err}",
328 params.command
329 ));
330 }
331 CallToolResult::success(vec![Content::text(result)])
332 }
333 Err(e) => tool_error(format!("invoke_command failed: {e}")),
334 }
335 }
336
337 #[tool(
338 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
339 annotations(
340 read_only_hint = true,
341 destructive_hint = false,
342 idempotent_hint = true,
343 open_world_hint = false
344 )
345 )]
346 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
347 self.track_tool_call();
348 if !self.state.privacy.is_tool_enabled("screenshot") {
349 return tool_disabled("screenshot");
350 }
351 match self
352 .bridge
353 .get_native_handle(params.window_label.as_deref())
354 {
355 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
356 Ok(png_bytes) => {
357 use base64::Engine;
358 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
359 CallToolResult::success(vec![Content::image(b64, "image/png")])
360 }
361 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
362 },
363 Err(e) => tool_error(format!("cannot get window handle: {e}")),
364 }
365 }
366
367 #[tool(
368 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
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 verify_state(
377 &self,
378 Parameters(params): Parameters<VerifyStateParams>,
379 ) -> CallToolResult {
380 if !self.state.privacy.is_tool_enabled("eval_js") {
381 return tool_disabled("verify_state requires eval_js capability");
382 }
383 let code = format!("return ({})", params.frontend_expr);
384 let frontend_json = match self
385 .eval_with_return(&code, params.webview_label.as_deref())
386 .await
387 {
388 Ok(result) => result,
389 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
390 };
391
392 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
393 Ok(v) => v,
394 Err(e) => {
395 return tool_error(format!(
396 "frontend expression did not return valid JSON: {e}"
397 ));
398 }
399 };
400
401 let backend_state = if let Some(state) = params.backend_state {
402 state
403 } else if let Some(ref cmd) = params.backend_command {
404 if !self.state.privacy.is_command_allowed(cmd) {
405 return tool_error(format!(
406 "command '{cmd}' is blocked by privacy configuration"
407 ));
408 }
409 let args = params.backend_args.unwrap_or(serde_json::json!({}));
410 let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
411 let invoke_code = format!(
412 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
413 js_string(cmd)
414 );
415 match self
416 .eval_with_return(&invoke_code, params.webview_label.as_deref())
417 .await
418 {
419 Ok(result) => match serde_json::from_str(&result) {
420 Ok(v) => v,
421 Err(e) => {
422 return tool_error(format!(
423 "backend command '{cmd}' did not return valid JSON: {e}"
424 ));
425 }
426 },
427 Err(e) => {
428 return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
429 }
430 }
431 } else {
432 return tool_error("either backend_state or backend_command must be provided");
433 };
434
435 let result = victauri_core::verify_state(frontend_state, backend_state);
436 json_result(&result)
437 }
438
439 #[tool(
440 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.",
441 annotations(
442 read_only_hint = true,
443 destructive_hint = false,
444 idempotent_hint = true,
445 open_world_hint = false
446 )
447 )]
448 async fn detect_ghost_commands(
449 &self,
450 Parameters(params): Parameters<GhostCommandParams>,
451 ) -> CallToolResult {
452 let code = "return window.__VICTAURI__?.getIpcLog()";
453 let ipc_json = match self
454 .eval_with_return(code, params.webview_label.as_deref())
455 .await
456 {
457 Ok(r) => r,
458 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
459 };
460
461 let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
462 Ok(v) => v,
463 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
464 };
465 let frontend_commands: Vec<String> = ipc_calls
466 .iter()
467 .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
468 .collect::<std::collections::HashSet<_>>()
469 .into_iter()
470 .collect();
471
472 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
473 json_result(&report)
474 }
475
476 #[tool(
477 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
478 annotations(
479 read_only_hint = true,
480 destructive_hint = false,
481 idempotent_hint = true,
482 open_world_hint = false
483 )
484 )]
485 async fn check_ipc_integrity(
486 &self,
487 Parameters(params): Parameters<IpcIntegrityParams>,
488 ) -> CallToolResult {
489 let threshold = params.stale_threshold_ms.unwrap_or(5000);
490 let code = format!(
491 r"return (function() {{
492 var log = window.__VICTAURI__?.getIpcLog() || [];
493 var now = Date.now();
494 var threshold = {threshold};
495 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
496 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
497 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
498 var net = window.__VICTAURI__?.getNetworkLog() || [];
499 var warning = null;
500 if (log.length === 0 && net.length > 5) {{
501 warning = 'Zero IPC calls captured but ' + net.length + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.';
502 }}
503 return {{
504 healthy: stale.length === 0 && errored.length === 0,
505 total_calls: log.length,
506 pending_count: pending.length,
507 stale_count: stale.length,
508 error_count: errored.length,
509 stale_calls: stale.slice(0, 20),
510 errored_calls: errored.slice(0, 20),
511 warning: warning
512 }};
513 }})()"
514 );
515 self.eval_bridge(&code, params.webview_label.as_deref())
516 .await
517 }
518
519 #[tool(
520 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).",
521 annotations(
522 read_only_hint = true,
523 destructive_hint = false,
524 idempotent_hint = true,
525 open_world_hint = false
526 )
527 )]
528 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
529 let value = params
530 .value
531 .as_ref()
532 .map_or_else(|| "null".to_string(), |v| js_string(v));
533 let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
534 let poll = params.poll_ms.unwrap_or(200);
535 let code = format!(
536 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
537 js_string(params.condition.as_str())
538 );
539 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
540 match self
541 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
542 .await
543 {
544 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
545 Err(e) => tool_error(e),
546 }
547 }
548
549 #[tool(
550 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.",
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 assert_semantic(
559 &self,
560 Parameters(params): Parameters<SemanticAssertParams>,
561 ) -> CallToolResult {
562 if !self.state.privacy.is_tool_enabled("eval_js") {
563 return tool_disabled("assert_semantic requires eval_js capability");
564 }
565 let code = format!("return ({})", params.expression);
566 let actual_json = match self
567 .eval_with_return(&code, params.webview_label.as_deref())
568 .await
569 {
570 Ok(result) => result,
571 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
572 };
573
574 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
575 Ok(v) => v,
576 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
577 };
578
579 let assertion = victauri_core::SemanticAssertion {
580 label: params.label,
581 condition: params.condition,
582 expected: params.expected,
583 };
584
585 let result = victauri_core::evaluate_assertion(actual, &assertion);
586 json_result(&result)
587 }
588
589 #[tool(
590 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
591 annotations(
592 read_only_hint = true,
593 destructive_hint = false,
594 idempotent_hint = true,
595 open_world_hint = false
596 )
597 )]
598 async fn resolve_command(
599 &self,
600 Parameters(params): Parameters<ResolveCommandParams>,
601 ) -> CallToolResult {
602 self.track_tool_call();
603 let limit = params.limit.unwrap_or(5);
604 let mut results = self.state.registry.resolve(¶ms.query);
605 results.truncate(limit);
606 json_result(&results)
607 }
608
609 #[tool(
610 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.",
611 annotations(
612 read_only_hint = true,
613 destructive_hint = false,
614 idempotent_hint = true,
615 open_world_hint = false
616 )
617 )]
618 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
619 self.track_tool_call();
620 let commands = match params.query {
621 Some(q) => self.state.registry.search(&q),
622 None => self.state.registry.list(),
623 };
624 json_result(&commands)
625 }
626
627 #[tool(
628 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.",
629 annotations(
630 read_only_hint = true,
631 destructive_hint = false,
632 idempotent_hint = true,
633 open_world_hint = false
634 )
635 )]
636 async fn get_memory_stats(&self) -> CallToolResult {
637 self.track_tool_call();
638 let stats = crate::memory::current_stats();
639 json_result(&stats)
640 }
641
642 #[tool(
643 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.",
644 annotations(
645 read_only_hint = true,
646 destructive_hint = false,
647 idempotent_hint = true,
648 open_world_hint = false
649 )
650 )]
651 async fn get_plugin_info(&self) -> CallToolResult {
652 self.track_tool_call();
653 let disabled: Vec<&str> = self
654 .state
655 .privacy
656 .disabled_tools
657 .iter()
658 .map(std::string::String::as_str)
659 .collect();
660 let blocklist: Vec<&str> = self
661 .state
662 .privacy
663 .command_blocklist
664 .iter()
665 .map(std::string::String::as_str)
666 .collect();
667 let allowlist: Option<Vec<&str>> = self
668 .state
669 .privacy
670 .command_allowlist
671 .as_ref()
672 .map(|s| s.iter().map(std::string::String::as_str).collect());
673 let all_tools = Self::tool_router().list_all();
674 let enabled_tools: Vec<&str> = all_tools
675 .iter()
676 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
677 .map(|t| t.name.as_ref())
678 .collect();
679
680 let result = serde_json::json!({
681 "version": env!("CARGO_PKG_VERSION"),
682 "bridge_version": BRIDGE_VERSION,
683 "port": self.state.port.load(Ordering::Relaxed),
684 "tools": {
685 "total": all_tools.len(),
686 "enabled": enabled_tools.len(),
687 "enabled_list": enabled_tools,
688 "disabled_list": disabled,
689 },
690 "commands": {
691 "allowlist": allowlist,
692 "blocklist": blocklist,
693 },
694 "privacy": {
695 "profile": self.state.privacy.profile.to_string(),
696 "redaction_enabled": self.state.privacy.redaction_enabled,
697 },
698 "capacities": {
699 "event_log": self.state.event_log.capacity(),
700 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
701 },
702 "registered_commands": self.state.registry.count(),
703 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
704 "uptime_secs": self.state.started_at.elapsed().as_secs(),
705 });
706 json_result(&result)
707 }
708
709 #[tool(
710 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.",
711 annotations(
712 read_only_hint = true,
713 destructive_hint = false,
714 idempotent_hint = true,
715 open_world_hint = false
716 )
717 )]
718 async fn get_diagnostics(
719 &self,
720 Parameters(params): Parameters<DiagnosticsParams>,
721 ) -> CallToolResult {
722 self.eval_bridge(
723 "return window.__VICTAURI__?.getDiagnostics()",
724 params.webview_label.as_deref(),
725 )
726 .await
727 }
728
729 #[tool(
732 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.",
733 annotations(
734 read_only_hint = true,
735 destructive_hint = false,
736 idempotent_hint = true,
737 open_world_hint = false
738 )
739 )]
740 async fn app_info(&self) -> CallToolResult {
741 self.track_tool_call();
742 let config = self.bridge.tauri_config();
743
744 let data_dir = self.bridge.app_data_dir().ok();
745 let config_dir = self.bridge.app_config_dir().ok();
746 let log_dir = self.bridge.app_log_dir().ok();
747 let local_data_dir = self.bridge.app_local_data_dir().ok();
748
749 let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
750 .filter(|(k, _)| {
751 let upper = k.to_uppercase();
752 SAFE_ENV_PREFIXES
753 .iter()
754 .any(|prefix| upper.starts_with(prefix))
755 })
756 .collect();
757
758 #[cfg(feature = "sqlite")]
759 let databases: Vec<String> = data_dir
760 .as_ref()
761 .map(|d| {
762 crate::database::discover_databases(d)
763 .into_iter()
764 .filter_map(|p| {
765 p.strip_prefix(d)
766 .ok()
767 .map(|rel| rel.to_string_lossy().into_owned())
768 })
769 .collect()
770 })
771 .unwrap_or_default();
772
773 #[cfg(not(feature = "sqlite"))]
774 let databases: Vec<String> = Vec::new();
775
776 let result = serde_json::json!({
777 "config": config,
778 "paths": {
779 "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
780 "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
781 "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
782 "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
783 },
784 "databases": databases,
785 "env": env_vars,
786 "process": {
787 "pid": std::process::id(),
788 "arch": std::env::consts::ARCH,
789 "os": std::env::consts::OS,
790 "family": std::env::consts::FAMILY,
791 },
792 });
793 json_result(&result)
794 }
795
796 #[tool(
797 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.",
798 annotations(
799 read_only_hint = true,
800 destructive_hint = false,
801 idempotent_hint = true,
802 open_world_hint = false
803 )
804 )]
805 async fn list_app_dir(
806 &self,
807 Parameters(params): Parameters<ListAppDirParams>,
808 ) -> CallToolResult {
809 self.track_tool_call();
810 let base = match self.resolve_app_dir(params.directory) {
811 Ok(d) => d,
812 Err(e) => return tool_error(e),
813 };
814
815 let target = if let Some(ref sub) = params.path {
816 let resolved = base.join(sub);
817 if !resolved.exists() {
818 return tool_error(format!("directory does not exist: {}", resolved.display()));
819 }
820 if let Err(e) = Self::safe_within(&base, &resolved) {
821 return tool_error(e);
822 }
823 resolved
824 } else {
825 base.clone()
826 };
827
828 if !target.exists() {
829 return tool_error(format!("directory does not exist: {}", target.display()));
830 }
831
832 let max_depth = params.max_depth.unwrap_or(1).min(5);
833 let pattern = params.pattern.as_deref();
834 let mut entries = Vec::new();
835
836 Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
837
838 json_result(&serde_json::json!({
839 "base": base.to_string_lossy(),
840 "path": params.path.unwrap_or_default(),
841 "entries": entries,
842 "count": entries.len(),
843 }))
844 }
845
846 #[tool(
847 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.",
848 annotations(
849 read_only_hint = true,
850 destructive_hint = false,
851 idempotent_hint = true,
852 open_world_hint = false
853 )
854 )]
855 async fn read_app_file(
856 &self,
857 Parameters(params): Parameters<ReadAppFileParams>,
858 ) -> CallToolResult {
859 self.track_tool_call();
860 let base = match self.resolve_app_dir(params.directory) {
861 Ok(d) => d,
862 Err(e) => return tool_error(e),
863 };
864
865 let target = base.join(¶ms.path);
866 if !target.exists() {
867 return tool_error(format!("file not found: {}", params.path));
868 }
869 if let Err(e) = Self::safe_within(&base, &target) {
870 return tool_error(e);
871 }
872 if !target.is_file() {
873 return tool_error(format!("not a file: {}", params.path));
874 }
875
876 let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
877 let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
878
879 match std::fs::read(&target) {
880 Ok(mut bytes) => {
881 let original_size = bytes.len();
882 let truncated = bytes.len() > max_bytes;
883 if truncated {
884 bytes.truncate(max_bytes);
885 }
886
887 let file_info = serde_json::json!({
888 "path": params.path,
889 "size": original_size,
890 "truncated": truncated,
891 "modified": metadata.as_ref().ok()
892 .and_then(|m| m.modified().ok())
893 .map(|t| {
894 let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
895 duration.as_secs()
896 }),
897 });
898
899 if params.binary == Some(true) {
900 use base64::Engine;
901 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
902 json_result(&serde_json::json!({
903 "file": file_info,
904 "encoding": "base64",
905 "content": b64,
906 }))
907 } else {
908 match String::from_utf8(bytes) {
909 Ok(text) => json_result(&serde_json::json!({
910 "file": file_info,
911 "encoding": "utf-8",
912 "content": text,
913 })),
914 Err(e) => {
915 use base64::Engine;
916 let bytes = e.into_bytes();
917 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
918 json_result(&serde_json::json!({
919 "file": file_info,
920 "encoding": "base64",
921 "note": "file is not valid UTF-8, returning base64",
922 "content": b64,
923 }))
924 }
925 }
926 }
927 }
928 Err(e) => tool_error(format!("failed to read file: {e}")),
929 }
930 }
931
932 #[cfg(feature = "sqlite")]
933 #[tool(
934 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.",
935 annotations(
936 read_only_hint = true,
937 destructive_hint = false,
938 idempotent_hint = true,
939 open_world_hint = false
940 )
941 )]
942 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
943 self.track_tool_call();
944 let data_dir = match self.bridge.app_data_dir() {
945 Ok(d) => d,
946 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
947 };
948
949 let search_dirs: Vec<std::path::PathBuf> = [
950 self.bridge.app_data_dir(),
951 self.bridge.app_config_dir(),
952 self.bridge.app_local_data_dir(),
953 self.bridge.app_log_dir(),
954 ]
955 .into_iter()
956 .filter_map(Result::ok)
957 .collect::<std::collections::HashSet<_>>()
958 .into_iter()
959 .collect();
960
961 let db_path = if let Some(ref rel_path) = params.path {
962 let mut found = None;
963 for dir in &search_dirs {
964 let resolved = dir.join(rel_path);
965 if resolved.exists() {
966 if let Err(e) = Self::safe_within(dir, &resolved) {
967 return tool_error(e);
968 }
969 found = Some(resolved);
970 break;
971 }
972 }
973 if let Some(p) = found {
974 p
975 } else {
976 let dirs_str = search_dirs
977 .iter()
978 .map(|d| d.display().to_string())
979 .collect::<Vec<_>>()
980 .join(", ");
981 return tool_error(format!(
982 "database not found: {rel_path} (searched: {dirs_str})"
983 ));
984 }
985 } else {
986 let mut databases = Vec::new();
987 for dir in &search_dirs {
988 databases.extend(crate::database::discover_databases(dir));
989 }
990 if let Some(p) = databases.first() {
991 p.clone()
992 } else {
993 let dirs_str = search_dirs
994 .iter()
995 .map(|d| d.display().to_string())
996 .collect::<Vec<_>>()
997 .join(", ");
998 return tool_error(format!("no SQLite databases found in: {dirs_str}"));
999 }
1000 };
1001
1002 let db_display = db_path
1003 .strip_prefix(&data_dir)
1004 .unwrap_or(&db_path)
1005 .to_string_lossy()
1006 .into_owned();
1007 let bind_params = params.params.unwrap_or_default();
1008
1009 match crate::database::query(&db_path, ¶ms.query, &bind_params, params.max_rows) {
1010 Ok(mut result) => {
1011 if let Some(obj) = result.as_object_mut() {
1012 obj.insert("database".to_string(), serde_json::json!(db_display));
1013 }
1014 json_result(&result)
1015 }
1016 Err(e) => tool_error(e),
1017 }
1018 }
1019
1020 #[tool(
1023 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.",
1024 annotations(
1025 read_only_hint = false,
1026 destructive_hint = false,
1027 idempotent_hint = false,
1028 open_world_hint = false
1029 )
1030 )]
1031 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1032 if !self.state.privacy.is_tool_enabled("interact") {
1033 return tool_disabled("interact");
1034 }
1035 match params.action {
1036 InteractAction::Click => {
1037 if !self.state.privacy.is_tool_enabled("interact.click") {
1038 return tool_disabled("interact.click");
1039 }
1040 let Some(ref_id) = ¶ms.ref_id else {
1041 return missing_param("ref_id", "click");
1042 };
1043 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1044 self.eval_bridge(&code, params.webview_label.as_deref())
1045 .await
1046 }
1047 InteractAction::DoubleClick => {
1048 if !self.state.privacy.is_tool_enabled("interact.double_click") {
1049 return tool_disabled("interact.double_click");
1050 }
1051 let Some(ref_id) = ¶ms.ref_id else {
1052 return missing_param("ref_id", "double_click");
1053 };
1054 let code = format!(
1055 "return window.__VICTAURI__?.doubleClick({})",
1056 js_string(ref_id)
1057 );
1058 self.eval_bridge(&code, params.webview_label.as_deref())
1059 .await
1060 }
1061 InteractAction::Hover => {
1062 if !self.state.privacy.is_tool_enabled("interact.hover") {
1063 return tool_disabled("interact.hover");
1064 }
1065 let Some(ref_id) = ¶ms.ref_id else {
1066 return missing_param("ref_id", "hover");
1067 };
1068 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1069 self.eval_bridge(&code, params.webview_label.as_deref())
1070 .await
1071 }
1072 InteractAction::Focus => {
1073 if !self.state.privacy.is_tool_enabled("interact.focus") {
1074 return tool_disabled("interact.focus");
1075 }
1076 let Some(ref_id) = ¶ms.ref_id else {
1077 return missing_param("ref_id", "focus");
1078 };
1079 let code = format!(
1080 "return window.__VICTAURI__?.focusElement({})",
1081 js_string(ref_id)
1082 );
1083 self.eval_bridge(&code, params.webview_label.as_deref())
1084 .await
1085 }
1086 InteractAction::ScrollIntoView => {
1087 if !self
1088 .state
1089 .privacy
1090 .is_tool_enabled("interact.scroll_into_view")
1091 {
1092 return tool_disabled("interact.scroll_into_view");
1093 }
1094 let ref_arg = params
1095 .ref_id
1096 .as_ref()
1097 .map_or_else(|| "null".to_string(), |r| js_string(r));
1098 let x = params.x.unwrap_or(0.0);
1099 let y = params.y.unwrap_or(0.0);
1100 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1101 self.eval_bridge(&code, params.webview_label.as_deref())
1102 .await
1103 }
1104 InteractAction::SelectOption => {
1105 if !self.state.privacy.is_tool_enabled("interact.select_option") {
1106 return tool_disabled("interact.select_option");
1107 }
1108 let Some(ref_id) = ¶ms.ref_id else {
1109 return missing_param("ref_id", "select_option");
1110 };
1111 let values = params.values.as_deref().unwrap_or(&[]);
1112 let values_json =
1113 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1114 let code = format!(
1115 "return window.__VICTAURI__?.selectOption({}, {})",
1116 js_string(ref_id),
1117 values_json
1118 );
1119 self.eval_bridge(&code, params.webview_label.as_deref())
1120 .await
1121 }
1122 }
1123 }
1124
1125 #[tool(
1126 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.",
1127 annotations(
1128 read_only_hint = false,
1129 destructive_hint = false,
1130 idempotent_hint = false,
1131 open_world_hint = false
1132 )
1133 )]
1134 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1135 match params.action {
1136 InputAction::Fill => {
1137 if !self.state.privacy.is_tool_enabled("fill") {
1138 return tool_disabled("fill");
1139 }
1140 let Some(ref_id) = ¶ms.ref_id else {
1141 return missing_param("ref_id", "fill");
1142 };
1143 let Some(value) = ¶ms.value else {
1144 return missing_param("value", "fill");
1145 };
1146 let code = format!(
1147 "return window.__VICTAURI__?.fill({}, {})",
1148 js_string(ref_id),
1149 js_string(value)
1150 );
1151 self.eval_bridge(&code, params.webview_label.as_deref())
1152 .await
1153 }
1154 InputAction::TypeText => {
1155 if !self.state.privacy.is_tool_enabled("type_text") {
1156 return tool_disabled("type_text");
1157 }
1158 let Some(ref_id) = ¶ms.ref_id else {
1159 return missing_param("ref_id", "type_text");
1160 };
1161 let Some(text) = ¶ms.text else {
1162 return missing_param("text", "type_text");
1163 };
1164 let code = format!(
1165 "return window.__VICTAURI__?.type({}, {})",
1166 js_string(ref_id),
1167 js_string(text)
1168 );
1169 self.eval_bridge(&code, params.webview_label.as_deref())
1170 .await
1171 }
1172 InputAction::PressKey => {
1173 if !self.state.privacy.is_tool_enabled("input.press_key") {
1174 return tool_disabled("input.press_key");
1175 }
1176 let Some(key) = ¶ms.key else {
1177 return missing_param("key", "press_key");
1178 };
1179 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1180 self.eval_bridge(&code, params.webview_label.as_deref())
1181 .await
1182 }
1183 }
1184 }
1185
1186 #[tool(
1187 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.",
1188 annotations(
1189 read_only_hint = false,
1190 destructive_hint = false,
1191 idempotent_hint = true,
1192 open_world_hint = false
1193 )
1194 )]
1195 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1196 self.track_tool_call();
1197 match params.action {
1198 WindowAction::GetState => {
1199 let states = self.bridge.get_window_states(params.label.as_deref());
1200 json_result(&states)
1201 }
1202 WindowAction::List => {
1203 let labels = self.bridge.list_window_labels();
1204 json_result(&labels)
1205 }
1206 WindowAction::Manage => {
1207 if !self.state.privacy.is_tool_enabled("window.manage") {
1208 return tool_disabled("window.manage");
1209 }
1210 let Some(manage_action) = ¶ms.manage_action else {
1211 return missing_param("manage_action", "manage");
1212 };
1213 match self
1214 .bridge
1215 .manage_window(params.label.as_deref(), manage_action.as_str())
1216 {
1217 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1218 Err(e) => tool_error(e),
1219 }
1220 }
1221 WindowAction::Resize => {
1222 if !self.state.privacy.is_tool_enabled("window.resize") {
1223 return tool_disabled("window.resize");
1224 }
1225 let Some(width) = params.width else {
1226 return missing_param("width", "resize");
1227 };
1228 let Some(height) = params.height else {
1229 return missing_param("height", "resize");
1230 };
1231 match self
1232 .bridge
1233 .resize_window(params.label.as_deref(), width, height)
1234 {
1235 Ok(()) => {
1236 let result =
1237 serde_json::json!({"ok": true, "width": width, "height": height});
1238 CallToolResult::success(vec![Content::text(result.to_string())])
1239 }
1240 Err(e) => tool_error(e),
1241 }
1242 }
1243 WindowAction::MoveTo => {
1244 if !self.state.privacy.is_tool_enabled("window.move_to") {
1245 return tool_disabled("window.move_to");
1246 }
1247 let Some(x) = params.x else {
1248 return missing_param("x", "move_to");
1249 };
1250 let Some(y) = params.y else {
1251 return missing_param("y", "move_to");
1252 };
1253 match self.bridge.move_window(params.label.as_deref(), x, y) {
1254 Ok(()) => {
1255 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1256 CallToolResult::success(vec![Content::text(result.to_string())])
1257 }
1258 Err(e) => tool_error(e),
1259 }
1260 }
1261 WindowAction::SetTitle => {
1262 if !self.state.privacy.is_tool_enabled("window.set_title") {
1263 return tool_disabled("window.set_title");
1264 }
1265 let Some(title) = ¶ms.title else {
1266 return missing_param("title", "set_title");
1267 };
1268 match self.bridge.set_window_title(params.label.as_deref(), title) {
1269 Ok(()) => {
1270 let result = serde_json::json!({"ok": true, "title": title});
1271 CallToolResult::success(vec![Content::text(result.to_string())])
1272 }
1273 Err(e) => tool_error(e),
1274 }
1275 }
1276 }
1277 }
1278
1279 #[tool(
1280 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1281 annotations(
1282 read_only_hint = false,
1283 destructive_hint = true,
1284 idempotent_hint = false,
1285 open_world_hint = false
1286 )
1287 )]
1288 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1289 match params.action {
1290 StorageAction::Get => {
1291 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1292 StorageType::Session => "getSessionStorage",
1293 StorageType::Local => "getLocalStorage",
1294 };
1295 let key_arg = params
1296 .key
1297 .as_ref()
1298 .map(|k| js_string(k))
1299 .unwrap_or_default();
1300 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1301 self.eval_bridge(&code, params.webview_label.as_deref())
1302 .await
1303 }
1304 StorageAction::Set => {
1305 if !self.state.privacy.is_tool_enabled("set_storage") {
1306 return tool_disabled("set_storage");
1307 }
1308 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1309 StorageType::Session => "setSessionStorage",
1310 StorageType::Local => "setLocalStorage",
1311 };
1312 let Some(key) = ¶ms.key else {
1313 return missing_param("key", "set");
1314 };
1315 let value = params
1316 .value
1317 .as_ref()
1318 .cloned()
1319 .unwrap_or(serde_json::Value::Null);
1320 let value_json =
1321 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1322 let code = format!(
1323 "return window.__VICTAURI__?.{method}({}, {value_json})",
1324 js_string(key)
1325 );
1326 self.eval_bridge(&code, params.webview_label.as_deref())
1327 .await
1328 }
1329 StorageAction::Delete => {
1330 if !self.state.privacy.is_tool_enabled("delete_storage") {
1331 return tool_disabled("delete_storage");
1332 }
1333 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1334 StorageType::Session => "deleteSessionStorage",
1335 StorageType::Local => "deleteLocalStorage",
1336 };
1337 let Some(key) = ¶ms.key else {
1338 return missing_param("key", "delete");
1339 };
1340 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1341 self.eval_bridge(&code, params.webview_label.as_deref())
1342 .await
1343 }
1344 StorageAction::GetCookies => {
1345 self.eval_bridge(
1346 "return window.__VICTAURI__?.getCookies()",
1347 params.webview_label.as_deref(),
1348 )
1349 .await
1350 }
1351 }
1352 }
1353
1354 #[tool(
1355 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.",
1356 annotations(
1357 read_only_hint = false,
1358 destructive_hint = false,
1359 idempotent_hint = false,
1360 open_world_hint = false
1361 )
1362 )]
1363 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1364 match params.action {
1365 NavigateAction::GoTo => {
1366 if !self.state.privacy.is_tool_enabled("navigate") {
1367 return tool_disabled("navigate");
1368 }
1369 let Some(url) = ¶ms.url else {
1370 return missing_param("url", "go_to");
1371 };
1372 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1373 return tool_error(e);
1374 }
1375 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1376 self.eval_bridge(&code, params.webview_label.as_deref())
1377 .await
1378 }
1379 NavigateAction::GoBack => {
1380 self.eval_bridge(
1381 "return window.__VICTAURI__?.navigateBack()",
1382 params.webview_label.as_deref(),
1383 )
1384 .await
1385 }
1386 NavigateAction::GetHistory => {
1387 self.eval_bridge(
1388 "return window.__VICTAURI__?.getNavigationLog()",
1389 params.webview_label.as_deref(),
1390 )
1391 .await
1392 }
1393 NavigateAction::SetDialogResponse => {
1394 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1395 return tool_disabled("set_dialog_response");
1396 }
1397 let Some(dialog_type) = params.dialog_type else {
1398 return missing_param("dialog_type", "set_dialog_response");
1399 };
1400 let Some(dialog_action) = params.dialog_action else {
1401 return missing_param("dialog_action", "set_dialog_response");
1402 };
1403 let text_arg = params
1404 .text
1405 .as_ref()
1406 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1407 let code = format!(
1408 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1409 js_string(dialog_type.as_str()),
1410 js_string(dialog_action.as_str())
1411 );
1412 self.eval_bridge(&code, params.webview_label.as_deref())
1413 .await
1414 }
1415 NavigateAction::GetDialogLog => {
1416 self.eval_bridge(
1417 "return window.__VICTAURI__?.getDialogLog()",
1418 params.webview_label.as_deref(),
1419 )
1420 .await
1421 }
1422 }
1423 }
1424
1425 #[tool(
1426 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), replay (re-execute recorded IPC commands and compare responses), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
1427 annotations(
1428 read_only_hint = false,
1429 destructive_hint = false,
1430 idempotent_hint = false,
1431 open_world_hint = false
1432 )
1433 )]
1434 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1435 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1436 self.track_tool_call();
1437 if !self.state.privacy.is_tool_enabled("recording") {
1438 return tool_disabled("recording");
1439 }
1440 match params.action {
1441 RecordingAction::Start => {
1442 let session_id = params
1443 .session_id
1444 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1445 match self.state.recorder.start(session_id.clone()) {
1446 Ok(()) => {
1447 let result = serde_json::json!({
1448 "started": true,
1449 "session_id": session_id,
1450 });
1451 CallToolResult::success(vec![Content::text(result.to_string())])
1452 }
1453 Err(e) => tool_error(e.to_string()),
1454 }
1455 }
1456 RecordingAction::Stop => match self.state.recorder.stop() {
1457 Some(session) => json_result(&session),
1458 None => tool_error("no recording is active"),
1459 },
1460 RecordingAction::Checkpoint => {
1461 let Some(id) = params.checkpoint_id else {
1462 return missing_param("checkpoint_id", "checkpoint");
1463 };
1464 let state = params.state.unwrap_or(serde_json::Value::Null);
1465 match self
1466 .state
1467 .recorder
1468 .checkpoint(id.clone(), params.checkpoint_label, state)
1469 {
1470 Ok(()) => {
1471 let result = serde_json::json!({
1472 "created": true,
1473 "checkpoint_id": id,
1474 "event_index": self.state.recorder.event_count(),
1475 });
1476 CallToolResult::success(vec![Content::text(result.to_string())])
1477 }
1478 Err(e) => tool_error(e.to_string()),
1479 }
1480 }
1481 RecordingAction::ListCheckpoints => {
1482 let checkpoints = self.state.recorder.get_checkpoints();
1483 json_result(&checkpoints)
1484 }
1485 RecordingAction::GetEvents => {
1486 let events = self
1487 .state
1488 .recorder
1489 .events_since(params.since_index.unwrap_or(0));
1490 json_result(&events)
1491 }
1492 RecordingAction::EventsBetween => {
1493 let Some(from) = ¶ms.from else {
1494 return missing_param("from", "events_between");
1495 };
1496 let Some(to) = ¶ms.to else {
1497 return missing_param("to", "events_between");
1498 };
1499 match self.state.recorder.events_between_checkpoints(from, to) {
1500 Ok(events) => json_result(&events),
1501 Err(e) => tool_error(e.to_string()),
1502 }
1503 }
1504 RecordingAction::GetReplay => {
1505 let calls = self.state.recorder.ipc_replay_sequence();
1506 json_result(&calls)
1507 }
1508 RecordingAction::Export => match self.state.recorder.export() {
1509 Some(s) => {
1510 let json = serde_json::to_string_pretty(&s)
1511 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1512 CallToolResult::success(vec![Content::text(json)])
1513 }
1514 None => tool_error("no recording is active — start one first"),
1515 },
1516 RecordingAction::Import => {
1517 let Some(session_json) = ¶ms.session_json else {
1518 return missing_param("session_json", "import");
1519 };
1520 if session_json.len() > MAX_SESSION_JSON {
1521 return tool_error("session JSON exceeds maximum size (10 MB)");
1522 }
1523 let session: victauri_core::RecordedSession =
1524 match serde_json::from_str(session_json) {
1525 Ok(s) => s,
1526 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1527 };
1528
1529 let result = serde_json::json!({
1530 "imported": true,
1531 "session_id": session.id,
1532 "event_count": session.events.len(),
1533 "checkpoint_count": session.checkpoints.len(),
1534 "started_at": session.started_at.to_rfc3339(),
1535 });
1536 self.state.recorder.import(session);
1537 CallToolResult::success(vec![Content::text(result.to_string())])
1538 }
1539 RecordingAction::Flush => {
1540 if !self.state.recorder.is_recording() {
1541 return tool_error("no active recording — start a recording first");
1542 }
1543 let code = "return window.__VICTAURI__?.getEventStream(0)";
1544 match self
1545 .eval_with_return(code, params.webview_label.as_deref())
1546 .await
1547 {
1548 Ok(result_str) => {
1549 let events: Vec<serde_json::Value> =
1550 serde_json::from_str(&result_str).unwrap_or_default();
1551 let mut count = 0u64;
1552 for ev in &events {
1553 if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1554 self.state.event_log.push(app_event.clone());
1555 self.state.recorder.record_event(app_event);
1556 count += 1;
1557 }
1558 }
1559 json_result(&serde_json::json!({
1560 "flushed": true,
1561 "events_captured": count,
1562 }))
1563 }
1564 Err(e) => tool_error(format!("flush failed: {e}")),
1565 }
1566 }
1567 RecordingAction::Replay => {
1568 let calls = self.state.recorder.ipc_replay_sequence();
1569 if calls.is_empty() {
1570 return tool_error("no IPC calls recorded — record a session first");
1571 }
1572 let mut replay_results = Vec::new();
1573 for call in &calls {
1574 let code = format!(
1575 "return window.__TAURI_INTERNALS__.invoke({})",
1576 js_string(&call.command)
1577 );
1578 let outcome = match self
1579 .eval_with_return(&code, params.webview_label.as_deref())
1580 .await
1581 {
1582 Ok(result_str) => {
1583 let value: serde_json::Value = serde_json::from_str(&result_str)
1584 .unwrap_or(serde_json::Value::String(result_str));
1585 let shape = crate::introspection::JsonShape::from_value(&value);
1586 serde_json::json!({
1587 "command": call.command,
1588 "status": "ok",
1589 "response_type": shape.type_name(),
1590 })
1591 }
1592 Err(e) => {
1593 serde_json::json!({
1594 "command": call.command,
1595 "status": "error",
1596 "error": e,
1597 })
1598 }
1599 };
1600 replay_results.push(outcome);
1601 }
1602 let passed = replay_results
1603 .iter()
1604 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1605 .count();
1606 let result = serde_json::json!({
1607 "replayed": replay_results.len(),
1608 "passed": passed,
1609 "failed": replay_results.len() - passed,
1610 "results": replay_results,
1611 });
1612 json_result(&result)
1613 }
1614 }
1615 }
1616
1617 #[tool(
1618 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).",
1619 annotations(
1620 read_only_hint = true,
1621 destructive_hint = false,
1622 idempotent_hint = true,
1623 open_world_hint = false
1624 )
1625 )]
1626 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1627 match params.action {
1628 InspectAction::GetStyles => {
1629 let Some(ref_id) = ¶ms.ref_id else {
1630 return missing_param("ref_id", "get_styles");
1631 };
1632 let props_arg = match ¶ms.properties {
1633 Some(props) => {
1634 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1635 format!("[{}]", arr.join(","))
1636 }
1637 None => "null".to_string(),
1638 };
1639 let code = format!(
1640 "return window.__VICTAURI__?.getStyles({}, {})",
1641 js_string(ref_id),
1642 props_arg
1643 );
1644 self.eval_bridge(&code, params.webview_label.as_deref())
1645 .await
1646 }
1647 InspectAction::GetBoundingBoxes => {
1648 let Some(ref_ids) = ¶ms.ref_ids else {
1649 return missing_param("ref_ids", "get_bounding_boxes");
1650 };
1651 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1652 let code = format!(
1653 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1654 refs.join(",")
1655 );
1656 self.eval_bridge(&code, params.webview_label.as_deref())
1657 .await
1658 }
1659 InspectAction::Highlight => {
1660 let Some(ref_id) = ¶ms.ref_id else {
1661 return missing_param("ref_id", "highlight");
1662 };
1663 let color_arg = match ¶ms.color {
1664 Some(c) => match sanitize_css_color(c) {
1665 Ok(safe) => format!("\"{safe}\""),
1666 Err(e) => return tool_error(e),
1667 },
1668 None => "null".to_string(),
1669 };
1670 let label_arg = match ¶ms.label {
1671 Some(l) => js_string(l),
1672 None => "null".to_string(),
1673 };
1674 let code = format!(
1675 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1676 js_string(ref_id),
1677 color_arg,
1678 label_arg
1679 );
1680 self.eval_bridge(&code, params.webview_label.as_deref())
1681 .await
1682 }
1683 InspectAction::ClearHighlights => {
1684 self.eval_bridge(
1685 "return window.__VICTAURI__?.clearHighlights()",
1686 params.webview_label.as_deref(),
1687 )
1688 .await
1689 }
1690 InspectAction::AuditAccessibility => {
1691 self.eval_bridge(
1692 "return window.__VICTAURI__?.auditAccessibility()",
1693 params.webview_label.as_deref(),
1694 )
1695 .await
1696 }
1697 InspectAction::GetPerformance => {
1698 self.eval_bridge(
1699 "return window.__VICTAURI__?.getPerformanceMetrics()",
1700 params.webview_label.as_deref(),
1701 )
1702 .await
1703 }
1704 }
1705 }
1706
1707 #[tool(
1708 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1709 annotations(
1710 read_only_hint = false,
1711 destructive_hint = false,
1712 idempotent_hint = true,
1713 open_world_hint = false
1714 )
1715 )]
1716 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1717 match params.action {
1718 CssAction::Inject => {
1719 if !self.state.privacy.is_tool_enabled("inject_css") {
1720 return tool_disabled("inject_css");
1721 }
1722 let Some(css) = ¶ms.css else {
1723 return missing_param("css", "inject");
1724 };
1725 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1726 self.eval_bridge(&code, params.webview_label.as_deref())
1727 .await
1728 }
1729 CssAction::Remove => {
1730 if !self.state.privacy.is_tool_enabled("css.remove") {
1731 return tool_disabled("css.remove");
1732 }
1733 self.eval_bridge(
1734 "return window.__VICTAURI__?.removeInjectedCss()",
1735 params.webview_label.as_deref(),
1736 )
1737 .await
1738 }
1739 }
1740 }
1741
1742 #[tool(
1743 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).",
1744 annotations(
1745 read_only_hint = true,
1746 destructive_hint = false,
1747 idempotent_hint = true,
1748 open_world_hint = false
1749 )
1750 )]
1751 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1752 match params.action {
1753 LogsAction::Console => {
1754 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1755 let code = if since_arg.is_empty() {
1756 "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1757 } else {
1758 format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1759 };
1760 self.eval_bridge(&code, params.webview_label.as_deref())
1761 .await
1762 }
1763 LogsAction::Network => {
1764 let filter_arg = params
1765 .filter
1766 .as_ref()
1767 .map_or_else(|| "null".to_string(), |f| js_string(f));
1768 let limit_arg = params
1769 .limit
1770 .map_or_else(|| "null".to_string(), |l| l.to_string());
1771 let code =
1772 format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1773 self.eval_bridge(&code, params.webview_label.as_deref())
1774 .await
1775 }
1776 LogsAction::Ipc => {
1777 let wait = params.wait_for_capture.unwrap_or(false);
1778 let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1779 if wait {
1780 let limit_js = if limit_arg.is_empty() {
1781 "undefined".to_string()
1782 } else {
1783 limit_arg.clone()
1784 };
1785 let code = format!(
1786 r"return (async function() {{
1787 await window.__VICTAURI__.waitForIpcComplete(500);
1788 var log = window.__VICTAURI__.getIpcLog() || [];
1789 var lim = {limit_js};
1790 return (lim !== undefined) ? log.slice(-lim) : log;
1791 }})()"
1792 );
1793 let timeout = std::time::Duration::from_millis(5000);
1794 match self
1795 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1796 .await
1797 {
1798 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1799 Err(e) => tool_error(e),
1800 }
1801 } else {
1802 let code = if limit_arg.is_empty() {
1803 "return window.__VICTAURI__?.getIpcLog()".to_string()
1804 } else {
1805 format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1806 };
1807 self.eval_bridge(&code, params.webview_label.as_deref())
1808 .await
1809 }
1810 }
1811 LogsAction::Navigation => {
1812 self.eval_bridge(
1813 "return window.__VICTAURI__?.getNavigationLog()",
1814 params.webview_label.as_deref(),
1815 )
1816 .await
1817 }
1818 LogsAction::Dialogs => {
1819 self.eval_bridge(
1820 "return window.__VICTAURI__?.getDialogLog()",
1821 params.webview_label.as_deref(),
1822 )
1823 .await
1824 }
1825 LogsAction::Events => {
1826 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1827 let code = if since_arg.is_empty() {
1828 "return window.__VICTAURI__?.getEventStream()".to_string()
1829 } else {
1830 format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1831 };
1832 self.eval_bridge(&code, params.webview_label.as_deref())
1833 .await
1834 }
1835 LogsAction::SlowIpc => {
1836 let Some(threshold) = params.threshold_ms else {
1837 return missing_param("threshold_ms", "slow_ipc");
1838 };
1839 let limit = params.limit.unwrap_or(20);
1840 let code = format!(
1841 r"return (function() {{
1842 var log = window.__VICTAURI__?.getIpcLog() || [];
1843 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1844 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1845 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1846 }})()",
1847 );
1848 self.eval_bridge(&code, None).await
1849 }
1850 }
1851 }
1852
1853 #[tool(
1856 description = "Deep backend introspection — command profiling, IPC contract testing, \
1857 coverage, startup timing, capability auditing, database diagnostics, process \
1858 enumeration, and event bus monitoring. \
1859 These features exploit Victauri's position inside the Rust process.\n\n\
1860 Actions:\n\
1861 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
1862 - `coverage`: Which registered commands have been called during this session.\n\
1863 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
1864 - `contract_check`: Check all recorded contracts for schema drift.\n\
1865 - `contract_list`: List all recorded contract baselines.\n\
1866 - `contract_clear`: Clear all recorded contract baselines.\n\
1867 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
1868 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
1869 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
1870 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
1871 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
1872 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
1873 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
1874 - `event_bus_clear`: Clear the event bus capture buffer.",
1875 annotations(
1876 read_only_hint = true,
1877 destructive_hint = false,
1878 idempotent_hint = true,
1879 open_world_hint = false
1880 )
1881 )]
1882 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
1883 self.track_tool_call();
1884 if !self.state.privacy.is_tool_enabled("introspect") {
1885 return tool_disabled("introspect");
1886 }
1887
1888 match params.action {
1889 IntrospectAction::CommandTimings => {
1890 let mut stats = self.state.command_timings.all_stats();
1891 if let Some(threshold) = params.slow_threshold_ms {
1892 stats.retain(|s| s.avg_ms >= threshold);
1893 }
1894 let result = serde_json::json!({
1895 "commands": stats,
1896 "total_commands_profiled": self.state.command_timings.all_stats().len(),
1897 "slow_threshold_ms": params.slow_threshold_ms,
1898 });
1899 json_result(&result)
1900 }
1901 IntrospectAction::Coverage => {
1902 let registered: Vec<String> = self
1903 .state
1904 .registry
1905 .list()
1906 .iter()
1907 .map(|c| c.name.clone())
1908 .collect();
1909
1910 let code = "return window.__VICTAURI__?.getIpcLog()";
1911 let invoked: std::collections::HashSet<String> = match self
1912 .eval_with_return(code, params.webview_label.as_deref())
1913 .await
1914 {
1915 Ok(json_str) => {
1916 if let Ok(entries) =
1917 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
1918 {
1919 entries
1920 .iter()
1921 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
1922 .map(String::from)
1923 .collect()
1924 } else {
1925 std::collections::HashSet::new()
1926 }
1927 }
1928 Err(_) => std::collections::HashSet::new(),
1929 };
1930
1931 let uncovered: Vec<&String> = registered
1932 .iter()
1933 .filter(|cmd| !invoked.contains(cmd.as_str()))
1934 .collect();
1935
1936 let coverage_pct = if registered.is_empty() {
1937 100.0
1938 } else {
1939 let covered = registered.len() - uncovered.len();
1940 (covered as f64 / registered.len() as f64) * 100.0
1941 };
1942
1943 let result = serde_json::json!({
1944 "registered_commands": registered.len(),
1945 "invoked_commands": invoked.len(),
1946 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
1947 "uncovered": uncovered,
1948 "invoked_not_registered": invoked.iter()
1949 .filter(|cmd| !registered.contains(cmd))
1950 .collect::<Vec<_>>(),
1951 });
1952 json_result(&result)
1953 }
1954 IntrospectAction::ContractRecord => {
1955 let Some(command) = params.command else {
1956 return missing_param("command", "contract_record");
1957 };
1958 let args_json = params.args.unwrap_or(serde_json::json!({}));
1959 let args_str =
1960 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1961 let code = format!(
1962 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1963 js_string(&command)
1964 );
1965 match self
1966 .eval_with_return(&code, params.webview_label.as_deref())
1967 .await
1968 {
1969 Ok(result_str) => {
1970 let value: serde_json::Value = serde_json::from_str(&result_str)
1971 .unwrap_or(serde_json::Value::String(result_str.clone()));
1972 let shape = crate::introspection::JsonShape::from_value(&value);
1973 let sample = if result_str.len() > 4096 {
1974 format!("{}...(truncated)", &result_str[..4096])
1975 } else {
1976 result_str
1977 };
1978 let baseline = crate::introspection::ContractBaseline {
1979 command: command.clone(),
1980 args: args_json,
1981 shape: shape.clone(),
1982 sample,
1983 recorded_at: chrono_now(),
1984 };
1985 self.state.contract_store.record(baseline);
1986 let result = serde_json::json!({
1987 "recorded": true,
1988 "command": command,
1989 "shape_type": shape.type_name(),
1990 });
1991 json_result(&result)
1992 }
1993 Err(e) => tool_error(format!(
1994 "failed to invoke '{command}' for contract recording: {e}"
1995 )),
1996 }
1997 }
1998 IntrospectAction::ContractCheck => {
1999 let baselines = self.state.contract_store.all();
2000 if baselines.is_empty() {
2001 return json_result(&serde_json::json!({
2002 "checked": 0,
2003 "message": "no contract baselines recorded — use contract_record first",
2004 }));
2005 }
2006 let mut results = Vec::new();
2007 for baseline in &baselines {
2008 let args_str =
2009 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2010 let code = format!(
2011 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2012 js_string(&baseline.command)
2013 );
2014 match self
2015 .eval_with_return(&code, params.webview_label.as_deref())
2016 .await
2017 {
2018 Ok(result_str) => {
2019 let value: serde_json::Value = serde_json::from_str(&result_str)
2020 .unwrap_or(serde_json::Value::String(result_str));
2021 let current_shape = crate::introspection::JsonShape::from_value(&value);
2022 let drift = crate::introspection::diff_shapes(
2023 &baseline.shape,
2024 ¤t_shape,
2025 &baseline.command,
2026 );
2027 results.push(drift);
2028 }
2029 Err(e) => {
2030 results.push(crate::introspection::ContractDrift {
2031 command: baseline.command.clone(),
2032 new_fields: Vec::new(),
2033 removed_fields: Vec::new(),
2034 type_changes: Vec::new(),
2035 shape_matches: false,
2036 });
2037 tracing::warn!(
2038 command = %baseline.command,
2039 error = %e,
2040 "contract check invocation failed"
2041 );
2042 }
2043 }
2044 }
2045 let passing = results.iter().filter(|r| r.shape_matches).count();
2046 let result = serde_json::json!({
2047 "checked": results.len(),
2048 "passing": passing,
2049 "failing": results.len() - passing,
2050 "contracts": results,
2051 });
2052 json_result(&result)
2053 }
2054 IntrospectAction::ContractList => {
2055 let baselines = self.state.contract_store.all();
2056 let result = serde_json::json!({
2057 "count": baselines.len(),
2058 "baselines": baselines.iter().map(|b| serde_json::json!({
2059 "command": b.command,
2060 "shape_type": b.shape.type_name(),
2061 "recorded_at": b.recorded_at,
2062 })).collect::<Vec<_>>(),
2063 });
2064 json_result(&result)
2065 }
2066 IntrospectAction::ContractClear => {
2067 let cleared = self.state.contract_store.clear();
2068 json_result(&serde_json::json!({
2069 "cleared": cleared,
2070 }))
2071 }
2072 IntrospectAction::StartupTiming => {
2073 let phases = self.state.startup_timeline.report();
2074 let result = serde_json::json!({
2075 "phases": phases,
2076 "total_ms": self.state.startup_timeline.total_ms(),
2077 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2078 });
2079 json_result(&result)
2080 }
2081 IntrospectAction::Capabilities => {
2082 let config = self.bridge.tauri_config();
2083 let live_windows = self.bridge.list_window_labels();
2084
2085 let result = serde_json::json!({
2086 "app": {
2087 "identifier": config.get("identifier"),
2088 "product_name": config.get("product_name"),
2089 "version": config.get("version"),
2090 },
2091 "security": config.get("security"),
2092 "configured_windows": config.get("windows"),
2093 "live_windows": live_windows,
2094 "configured_plugins": config.get("plugins"),
2095 "victauri": {
2096 "registered_commands": self.state.registry.list().len(),
2097 "auth_enabled": self.state.privacy.redaction_enabled,
2098 "privacy_profile": format!("{:?}", self.state.privacy.profile),
2099 "disabled_tools": &self.state.privacy.disabled_tools,
2100 },
2101 });
2102 json_result(&result)
2103 }
2104 #[allow(unused_variables)]
2105 IntrospectAction::DbHealth => {
2106 #[cfg(feature = "sqlite")]
2107 {
2108 let db_path = params.db_path.clone();
2109 match self.run_db_health(db_path.as_deref()).await {
2110 Ok(health) => json_result(&health),
2111 Err(e) => tool_error(format!("db_health failed: {e}")),
2112 }
2113 }
2114 #[cfg(not(feature = "sqlite"))]
2115 {
2116 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2117 }
2118 }
2119 IntrospectAction::PluginState => {
2120 let recording_active = self.state.recorder.is_recording();
2121 let recording_events = self.state.recorder.event_count();
2122 let result = serde_json::json!({
2123 "event_log": {
2124 "size": self.state.event_log.len(),
2125 "capacity": self.state.event_log.capacity(),
2126 },
2127 "registry": {
2128 "commands_registered": self.state.registry.list().len(),
2129 },
2130 "recording": {
2131 "active": recording_active,
2132 "events_captured": recording_events,
2133 },
2134 "faults": {
2135 "active_rules": self.state.fault_registry.list().len(),
2136 },
2137 "contracts": {
2138 "baselines_recorded": self.state.contract_store.all().len(),
2139 },
2140 "timings": {
2141 "commands_profiled": self.state.command_timings.all_stats().len(),
2142 },
2143 "event_bus": {
2144 "captured_events": self.state.event_bus.len(),
2145 },
2146 "tasks": {
2147 "total": self.state.task_tracker.list().len(),
2148 "active": self.state.task_tracker.active_count(),
2149 },
2150 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2151 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2152 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2153 });
2154 json_result(&result)
2155 }
2156 IntrospectAction::Processes => {
2157 let pid = std::process::id();
2158 let uptime = self.state.started_at.elapsed();
2159 let children = crate::introspection::enumerate_child_processes();
2160 let host_memory = crate::memory::current_stats();
2161
2162 let result = serde_json::json!({
2163 "host": {
2164 "pid": pid,
2165 "uptime_secs": uptime.as_secs(),
2166 "platform": std::env::consts::OS,
2167 "arch": std::env::consts::ARCH,
2168 "memory": host_memory,
2169 },
2170 "children": children.iter().map(|c| serde_json::json!({
2171 "pid": c.pid,
2172 "name": c.name,
2173 "memory_bytes": c.memory_bytes,
2174 })).collect::<Vec<_>>(),
2175 "child_count": children.len(),
2176 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2177 });
2178 json_result(&result)
2179 }
2180 IntrospectAction::PluginTasks => {
2181 let tasks = self.state.task_tracker.list();
2182 let active = self.state.task_tracker.active_count();
2183 let result = serde_json::json!({
2184 "total": tasks.len(),
2185 "active": active,
2186 "finished": tasks.len() - active,
2187 "tasks": tasks,
2188 });
2189 json_result(&result)
2190 }
2191 IntrospectAction::EventBus => {
2192 let tauri_events = self.state.event_bus.events();
2193 let app_events = self.state.event_log.snapshot();
2194 let result = serde_json::json!({
2195 "tauri_events": {
2196 "count": tauri_events.len(),
2197 "events": tauri_events,
2198 },
2199 "app_events": {
2200 "count": app_events.len(),
2201 "capacity": self.state.event_log.capacity(),
2202 "events": app_events,
2203 },
2204 });
2205 json_result(&result)
2206 }
2207 IntrospectAction::EventBusClear => {
2208 let tauri_cleared = self.state.event_bus.clear();
2209 self.state.event_log.clear();
2210 json_result(&serde_json::json!({
2211 "tauri_events_cleared": tauri_cleared,
2212 "app_events_cleared": true,
2213 }))
2214 }
2215 }
2216 }
2217
2218 #[tool(
2221 description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2222 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2223 CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2224 Actions:\n\
2225 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2226 - `list`: List all active fault injection rules.\n\
2227 - `clear`: Remove a specific fault rule (requires `command`).\n\
2228 - `clear_all`: Remove all fault rules.",
2229 annotations(
2230 read_only_hint = false,
2231 destructive_hint = true,
2232 idempotent_hint = false,
2233 open_world_hint = false
2234 )
2235 )]
2236 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2237 self.track_tool_call();
2238 if !self.state.privacy.is_tool_enabled("fault") {
2239 return tool_disabled("fault");
2240 }
2241
2242 match params.action {
2243 FaultAction::Inject => {
2244 let Some(command) = params.command else {
2245 return missing_param("command", "inject");
2246 };
2247 let Some(fault_kind) = params.fault_type else {
2248 return missing_param("fault_type", "inject");
2249 };
2250 let fault_type = match fault_kind {
2251 FaultKind::Delay => {
2252 let delay_ms = params.delay_ms.unwrap_or(1000);
2253 crate::introspection::FaultType::Delay { delay_ms }
2254 }
2255 FaultKind::Error => {
2256 let message = params
2257 .error_message
2258 .unwrap_or_else(|| "injected fault".to_string());
2259 crate::introspection::FaultType::Error { message }
2260 }
2261 FaultKind::Drop => crate::introspection::FaultType::Drop,
2262 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2263 };
2264 let config = crate::introspection::FaultConfig {
2265 command: command.clone(),
2266 fault_type: fault_type.clone(),
2267 trigger_count: 0,
2268 max_triggers: params.max_triggers.unwrap_or(0),
2269 created_at: std::time::Instant::now(),
2270 };
2271 self.state.fault_registry.inject(config);
2272 let result = serde_json::json!({
2273 "injected": true,
2274 "command": command,
2275 "fault_type": fault_type,
2276 "max_triggers": params.max_triggers.unwrap_or(0),
2277 });
2278 json_result(&result)
2279 }
2280 FaultAction::List => {
2281 let faults = self.state.fault_registry.list();
2282 let result = serde_json::json!({
2283 "count": faults.len(),
2284 "faults": faults.iter().map(|f| serde_json::json!({
2285 "command": f.command,
2286 "fault_type": f.fault_type,
2287 "trigger_count": f.trigger_count,
2288 "max_triggers": f.max_triggers,
2289 })).collect::<Vec<_>>(),
2290 });
2291 json_result(&result)
2292 }
2293 FaultAction::Clear => {
2294 let Some(command) = params.command else {
2295 return missing_param("command", "clear");
2296 };
2297 let removed = self.state.fault_registry.clear(&command);
2298 json_result(&serde_json::json!({
2299 "removed": removed,
2300 "command": command,
2301 }))
2302 }
2303 FaultAction::ClearAll => {
2304 let removed = self.state.fault_registry.clear_all();
2305 json_result(&serde_json::json!({
2306 "removed": removed,
2307 }))
2308 }
2309 }
2310 }
2311
2312 #[tool(
2315 description = "Correlate recent activity across all layers into a coherent narrative. \
2316 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2317 + window events across the Rust backend and webview simultaneously.\n\n\
2318 Actions:\n\
2319 - `summary`: High-level activity summary for the last N seconds (default 30). \
2320 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2321 - `last_action`: Correlate the most recent burst of events into a causal timeline \
2322 (e.g. 'IPC call → DOM update → console.log').\n\
2323 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2324 annotations(
2325 read_only_hint = true,
2326 destructive_hint = false,
2327 idempotent_hint = true,
2328 open_world_hint = false
2329 )
2330 )]
2331 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2332 self.track_tool_call();
2333 if !self.state.privacy.is_tool_enabled("explain") {
2334 return tool_disabled("explain");
2335 }
2336
2337 match params.action {
2338 ExplainAction::Summary => {
2339 let secs = params.seconds.unwrap_or(30);
2340 let since = chrono::Utc::now()
2341 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2342 let events = self.state.event_log.since(since);
2343
2344 let mut ipc_count = 0u64;
2345 let mut dom_mutations = 0u64;
2346 let mut state_changes = 0u64;
2347 let mut console_count = 0u64;
2348 let mut window_events = 0u64;
2349 let mut interactions = 0u64;
2350 let mut top_commands: HashMap<String, u64> = HashMap::new();
2351 let mut errors: Vec<String> = Vec::new();
2352
2353 for event in &events {
2354 match event {
2355 victauri_core::AppEvent::Ipc(call) => {
2356 ipc_count += 1;
2357 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2358 if let victauri_core::IpcResult::Err(e) = &call.result {
2359 errors.push(format!("IPC {}: {e}", call.command));
2360 }
2361 }
2362 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2363 dom_mutations += u64::from(*mutation_count)
2364 }
2365 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2366 victauri_core::AppEvent::Console { level, message, .. } => {
2367 console_count += 1;
2368 if level == "error" {
2369 errors.push(format!("console.error: {message}"));
2370 }
2371 }
2372 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2373 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2374 _ => {}
2375 }
2376 }
2377
2378 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2379 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2380 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2381
2382 let narrative = format!(
2383 "{ipc_count} IPC call{} in the last {secs}s{}. \
2384 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2385 {console_count} console message{}, {window_events} window event{}. {}.",
2386 if ipc_count == 1 { "" } else { "s" },
2387 if top.is_empty() {
2388 String::new()
2389 } else {
2390 format!(
2391 ", dominated by {}",
2392 top.iter()
2393 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2394 .collect::<Vec<_>>()
2395 .join(", ")
2396 )
2397 },
2398 if dom_mutations == 1 { "" } else { "s" },
2399 if interactions == 1 { "" } else { "s" },
2400 if console_count == 1 { "" } else { "s" },
2401 if window_events == 1 { "" } else { "s" },
2402 if errors.is_empty() {
2403 "No errors".to_string()
2404 } else {
2405 format!(
2406 "{} error{}",
2407 errors.len(),
2408 if errors.len() == 1 { "" } else { "s" }
2409 )
2410 },
2411 );
2412
2413 let result = serde_json::json!({
2414 "time_window_secs": secs,
2415 "total_events": events.len(),
2416 "ipc_calls": ipc_count,
2417 "dom_mutations": dom_mutations,
2418 "state_changes": state_changes,
2419 "console_messages": console_count,
2420 "window_events": window_events,
2421 "interactions": interactions,
2422 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2423 serde_json::json!({"command": cmd, "count": n})
2424 }).collect::<Vec<_>>(),
2425 "errors": errors,
2426 "narrative": narrative,
2427 });
2428 json_result(&result)
2429 }
2430 ExplainAction::LastAction => {
2431 let secs = params.seconds.unwrap_or(5);
2432 let since = chrono::Utc::now()
2433 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2434 let events = self.state.event_log.since(since);
2435
2436 let timeline: Vec<serde_json::Value> = events
2437 .iter()
2438 .filter(|e| !e.is_internal())
2439 .map(|event| match event {
2440 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
2441 "time": call.timestamp.to_rfc3339_opts(
2442 chrono::SecondsFormat::Millis, true
2443 ),
2444 "type": "ipc",
2445 "detail": format!(
2446 "{} {} ({}ms)",
2447 call.command,
2448 call.result,
2449 call.duration_ms.unwrap_or(0)
2450 ),
2451 }),
2452 victauri_core::AppEvent::DomMutation {
2453 timestamp,
2454 mutation_count,
2455 webview_label,
2456 } => serde_json::json!({
2457 "time": timestamp.to_rfc3339_opts(
2458 chrono::SecondsFormat::Millis, true
2459 ),
2460 "type": "dom_mutation",
2461 "detail": format!(
2462 "{mutation_count} element{} updated in {webview_label}",
2463 if *mutation_count == 1 { "" } else { "s" }
2464 ),
2465 }),
2466 victauri_core::AppEvent::DomInteraction {
2467 timestamp,
2468 action,
2469 selector,
2470 ..
2471 } => serde_json::json!({
2472 "time": timestamp.to_rfc3339_opts(
2473 chrono::SecondsFormat::Millis, true
2474 ),
2475 "type": "interaction",
2476 "detail": format!("{action} on {selector}"),
2477 }),
2478 victauri_core::AppEvent::StateChange {
2479 timestamp,
2480 key,
2481 caused_by,
2482 } => serde_json::json!({
2483 "time": timestamp.to_rfc3339_opts(
2484 chrono::SecondsFormat::Millis, true
2485 ),
2486 "type": "state_change",
2487 "detail": format!(
2488 "{key} changed{}",
2489 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2490 ),
2491 }),
2492 victauri_core::AppEvent::Console {
2493 timestamp,
2494 level,
2495 message,
2496 } => serde_json::json!({
2497 "time": timestamp.to_rfc3339_opts(
2498 chrono::SecondsFormat::Millis, true
2499 ),
2500 "type": "console",
2501 "detail": format!("console.{level}: {message}"),
2502 }),
2503 victauri_core::AppEvent::WindowEvent {
2504 timestamp,
2505 label,
2506 event,
2507 } => serde_json::json!({
2508 "time": timestamp.to_rfc3339_opts(
2509 chrono::SecondsFormat::Millis, true
2510 ),
2511 "type": "window_event",
2512 "detail": format!("{event} on window '{label}'"),
2513 }),
2514 _ => serde_json::json!({
2515 "time": event.timestamp().to_rfc3339_opts(
2516 chrono::SecondsFormat::Millis, true
2517 ),
2518 "type": "other",
2519 "detail": "unknown event type",
2520 }),
2521 })
2522 .collect();
2523
2524 let narrative = if timeline.is_empty() {
2525 format!("No activity in the last {secs}s.")
2526 } else {
2527 let parts: Vec<String> = timeline
2528 .iter()
2529 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2530 .map(String::from)
2531 .collect();
2532 parts.join(" → ")
2533 };
2534
2535 let result = serde_json::json!({
2536 "time_window_secs": secs,
2537 "event_count": timeline.len(),
2538 "timeline": timeline,
2539 "narrative": narrative,
2540 });
2541 json_result(&result)
2542 }
2543 ExplainAction::Diff => {
2544 let secs = params.seconds.unwrap_or(10);
2545 let since = chrono::Utc::now()
2546 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2547 let events = self.state.event_log.since(since);
2548
2549 let mut ipc_commands: Vec<String> = Vec::new();
2550 let mut dom_changes = 0u64;
2551 let mut error_count = 0u64;
2552 let mut interaction_count = 0u64;
2553 let mut console_messages = 0u64;
2554
2555 for event in &events {
2556 if event.is_internal() {
2557 continue;
2558 }
2559 match event {
2560 victauri_core::AppEvent::Ipc(call) => {
2561 ipc_commands.push(call.command.clone());
2562 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2563 error_count += 1;
2564 }
2565 }
2566 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2567 dom_changes += u64::from(*mutation_count)
2568 }
2569 victauri_core::AppEvent::DomInteraction { .. } => {
2570 interaction_count += 1;
2571 }
2572 victauri_core::AppEvent::Console { level, .. } => {
2573 console_messages += 1;
2574 if level == "error" {
2575 error_count += 1;
2576 }
2577 }
2578 _ => {}
2579 }
2580 }
2581
2582 ipc_commands.dedup();
2583
2584 let result = serde_json::json!({
2585 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2586 "time_window_secs": secs,
2587 "total_events": events.len(),
2588 "ipc_calls_made": ipc_commands.len(),
2589 "unique_commands": ipc_commands,
2590 "dom_elements_changed": dom_changes,
2591 "interactions": interaction_count,
2592 "console_messages": console_messages,
2593 "errors": error_count,
2594 });
2595 json_result(&result)
2596 }
2597 }
2598 }
2599}
2600
2601impl VictauriMcpHandler {
2602 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2604 Self {
2605 state,
2606 bridge,
2607 subscriptions: Arc::new(Mutex::new(HashSet::new())),
2608 bridge_checked: Arc::new(AtomicBool::new(false)),
2609 probed_labels: Arc::new(Mutex::new(HashSet::new())),
2610 }
2611 }
2612
2613 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2614 self.state.privacy.is_tool_enabled(name)
2615 }
2616
2617 pub(crate) async fn execute_tool(
2618 &self,
2619 name: &str,
2620 args: serde_json::Value,
2621 ) -> Result<CallToolResult, rest::ToolCallError> {
2622 if !self.state.privacy.is_tool_enabled(name) {
2623 return Ok(tool_disabled(name));
2624 }
2625 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2626 let start = std::time::Instant::now();
2627 tracing::debug!(tool = %name, "REST tool invocation started");
2628
2629 let result = match name {
2630 "eval_js" => {
2631 let p: EvalJsParams = Self::parse_args(args)?;
2632 self.eval_js(Parameters(p)).await
2633 }
2634 "dom_snapshot" => {
2635 let p: SnapshotParams = Self::parse_args(args)?;
2636 self.dom_snapshot(Parameters(p)).await
2637 }
2638 "find_elements" => {
2639 let p: FindElementsParams = Self::parse_args(args)?;
2640 self.find_elements(Parameters(p)).await
2641 }
2642 "invoke_command" => {
2643 let p: InvokeCommandParams = Self::parse_args(args)?;
2644 self.invoke_command(Parameters(p)).await
2645 }
2646 "screenshot" => {
2647 let p: ScreenshotParams = Self::parse_args(args)?;
2648 self.screenshot(Parameters(p)).await
2649 }
2650 "verify_state" => {
2651 let p: VerifyStateParams = Self::parse_args(args)?;
2652 self.verify_state(Parameters(p)).await
2653 }
2654 "detect_ghost_commands" => {
2655 let p: GhostCommandParams = Self::parse_args(args)?;
2656 self.detect_ghost_commands(Parameters(p)).await
2657 }
2658 "check_ipc_integrity" => {
2659 let p: IpcIntegrityParams = Self::parse_args(args)?;
2660 self.check_ipc_integrity(Parameters(p)).await
2661 }
2662 "wait_for" => {
2663 let p: WaitForParams = Self::parse_args(args)?;
2664 self.wait_for(Parameters(p)).await
2665 }
2666 "assert_semantic" => {
2667 let p: SemanticAssertParams = Self::parse_args(args)?;
2668 self.assert_semantic(Parameters(p)).await
2669 }
2670 "resolve_command" => {
2671 let p: ResolveCommandParams = Self::parse_args(args)?;
2672 self.resolve_command(Parameters(p)).await
2673 }
2674 "get_registry" => {
2675 let p: RegistryParams = Self::parse_args(args)?;
2676 self.get_registry(Parameters(p)).await
2677 }
2678 "get_memory_stats" => self.get_memory_stats().await,
2679 "get_plugin_info" => self.get_plugin_info().await,
2680 "get_diagnostics" => {
2681 let p: DiagnosticsParams = Self::parse_args(args)?;
2682 self.get_diagnostics(Parameters(p)).await
2683 }
2684 "app_info" => self.app_info().await,
2685 "list_app_dir" => {
2686 let p: ListAppDirParams = Self::parse_args(args)?;
2687 self.list_app_dir(Parameters(p)).await
2688 }
2689 "read_app_file" => {
2690 let p: ReadAppFileParams = Self::parse_args(args)?;
2691 self.read_app_file(Parameters(p)).await
2692 }
2693 #[cfg(feature = "sqlite")]
2694 "query_db" => {
2695 let p: QueryDbParams = Self::parse_args(args)?;
2696 self.query_db(Parameters(p)).await
2697 }
2698 "interact" => {
2699 let p: InteractParams = Self::parse_args(args)?;
2700 self.interact(Parameters(p)).await
2701 }
2702 "input" => {
2703 let p: InputParams = Self::parse_args(args)?;
2704 self.input(Parameters(p)).await
2705 }
2706 "window" => {
2707 let p: WindowParams = Self::parse_args(args)?;
2708 self.window(Parameters(p)).await
2709 }
2710 "storage" => {
2711 let p: StorageParams = Self::parse_args(args)?;
2712 self.storage(Parameters(p)).await
2713 }
2714 "navigate" => {
2715 let p: NavigateParams = Self::parse_args(args)?;
2716 self.navigate(Parameters(p)).await
2717 }
2718 "recording" => {
2719 let p: RecordingParams = Self::parse_args(args)?;
2720 self.recording(Parameters(p)).await
2721 }
2722 "inspect" => {
2723 let p: InspectParams = Self::parse_args(args)?;
2724 self.inspect(Parameters(p)).await
2725 }
2726 "css" => {
2727 let p: CssParams = Self::parse_args(args)?;
2728 self.css(Parameters(p)).await
2729 }
2730 "logs" => {
2731 let p: LogsParams = Self::parse_args(args)?;
2732 self.logs(Parameters(p)).await
2733 }
2734 "introspect" => {
2735 let p: IntrospectParams = Self::parse_args(args)?;
2736 self.introspect(Parameters(p)).await
2737 }
2738 "fault" => {
2739 let p: FaultParams = Self::parse_args(args)?;
2740 self.fault(Parameters(p)).await
2741 }
2742 "explain" => {
2743 let p: ExplainParams = Self::parse_args(args)?;
2744 self.explain(Parameters(p)).await
2745 }
2746 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
2747 };
2748
2749 let elapsed = start.elapsed();
2750 tracing::debug!(
2751 tool = %name,
2752 elapsed_ms = elapsed.as_millis() as u64,
2753 "REST tool invocation completed"
2754 );
2755
2756 if self.state.privacy.redaction_enabled {
2757 Ok(Self::redact_result(result, &self.state.privacy))
2758 } else {
2759 Ok(result)
2760 }
2761 }
2762
2763 fn parse_args<T: serde::de::DeserializeOwned>(
2764 args: serde_json::Value,
2765 ) -> Result<T, rest::ToolCallError> {
2766 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
2767 }
2768
2769 fn redact_result(
2770 mut result: CallToolResult,
2771 privacy: &crate::privacy::PrivacyConfig,
2772 ) -> CallToolResult {
2773 for item in &mut result.content {
2774 if let RawContent::Text(ref mut tc) = item.raw {
2775 tc.text = privacy.redact_output(&tc.text);
2776 }
2777 }
2778 result
2779 }
2780
2781 fn track_tool_call(&self) {
2782 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2783 }
2784
2785 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
2786 match dir.unwrap_or(AppDir::Data) {
2787 AppDir::Data => self.bridge.app_data_dir(),
2788 AppDir::Config => self.bridge.app_config_dir(),
2789 AppDir::Log => self.bridge.app_log_dir(),
2790 AppDir::LocalData => self.bridge.app_local_data_dir(),
2791 }
2792 }
2793
2794 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
2795 let canon_base = std::fs::canonicalize(base)
2796 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
2797 let canon_target = std::fs::canonicalize(target)
2798 .map_err(|e| format!("cannot resolve target path: {e}"))?;
2799 if !canon_target.starts_with(&canon_base) {
2800 return Err("path traversal not allowed".to_string());
2801 }
2802 Ok(())
2803 }
2804
2805 fn list_dir_recursive(
2806 dir: &std::path::Path,
2807 base: &std::path::Path,
2808 depth: u32,
2809 max_depth: u32,
2810 pattern: Option<&str>,
2811 entries: &mut Vec<serde_json::Value>,
2812 ) {
2813 let Ok(read_dir) = std::fs::read_dir(dir) else {
2814 return;
2815 };
2816 for entry in read_dir.flatten() {
2817 let path = entry.path();
2818 if path.is_symlink() {
2819 continue;
2820 }
2821 let name = entry.file_name().to_string_lossy().into_owned();
2822 let relative = path
2823 .strip_prefix(base)
2824 .unwrap_or(&path)
2825 .to_string_lossy()
2826 .into_owned();
2827
2828 if let Some(pat) = pattern
2829 && !Self::matches_glob(&name, pat)
2830 && !path.is_dir()
2831 {
2832 continue;
2833 }
2834
2835 let is_dir = path.is_dir();
2836 let meta = std::fs::metadata(&path).ok();
2837
2838 entries.push(serde_json::json!({
2839 "name": name,
2840 "path": relative,
2841 "is_dir": is_dir,
2842 "size": meta.as_ref().map(std::fs::Metadata::len),
2843 "modified": meta.as_ref()
2844 .and_then(|m| m.modified().ok())
2845 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
2846 .unwrap_or_default().as_secs()),
2847 }));
2848
2849 if is_dir && depth < max_depth {
2850 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
2851 }
2852 }
2853 }
2854
2855 fn matches_glob(name: &str, pattern: &str) -> bool {
2856 if pattern == "*" {
2857 return true;
2858 }
2859 if let Some(suffix) = pattern.strip_prefix("*.") {
2860 return name.ends_with(&format!(".{suffix}"));
2861 }
2862 if let Some(prefix) = pattern.strip_suffix("*") {
2863 return name.starts_with(prefix);
2864 }
2865 name == pattern
2866 }
2867
2868 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
2869 match self.eval_with_return(code, webview_label).await {
2870 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2871 Err(e) => tool_error(e),
2872 }
2873 }
2874
2875 async fn eval_with_return(
2876 &self,
2877 code: &str,
2878 webview_label: Option<&str>,
2879 ) -> Result<String, String> {
2880 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
2881 .await
2882 }
2883
2884 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
2885 let id = uuid::Uuid::new_v4().to_string();
2886 let (tx, rx) = tokio::sync::oneshot::channel();
2887 {
2888 let mut pending = self.state.pending_evals.lock().await;
2889 pending.insert(id.clone(), tx);
2890 }
2891 let id_js = js_string(&id);
2892 let probe = format!(
2893 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
2894 );
2895 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
2896 self.state.pending_evals.lock().await.remove(&id);
2897 return Err(format!("eval injection failed: {e}"));
2898 }
2899 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
2900 Ok(())
2901 } else {
2902 self.state.pending_evals.lock().await.remove(&id);
2903 let label = webview_label.unwrap_or("default");
2904 Err(format!(
2905 "bridge not responding on window '{label}' — the window may be hidden, \
2906 missing the victauri capability, or the JS bridge is not loaded"
2907 ))
2908 }
2909 }
2910
2911 async fn eval_with_return_timeout(
2912 &self,
2913 code: &str,
2914 webview_label: Option<&str>,
2915 timeout: std::time::Duration,
2916 ) -> Result<String, String> {
2917 self.track_tool_call();
2918
2919 if !self
2924 .state
2925 .bridge_ready
2926 .load(std::sync::atomic::Ordering::Acquire)
2927 {
2928 let notified = self.state.bridge_notify.notified();
2929 if !self
2930 .state
2931 .bridge_ready
2932 .load(std::sync::atomic::Ordering::Acquire)
2933 {
2934 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
2935 }
2936 }
2937
2938 if webview_label.is_some() {
2939 let label_key = webview_label.unwrap_or_default().to_string();
2940 let already_probed = self.probed_labels.lock().await.contains(&label_key);
2941 if !already_probed {
2942 self.probe_bridge(webview_label).await?;
2943 self.probed_labels.lock().await.insert(label_key);
2944 }
2945 }
2946
2947 let id = uuid::Uuid::new_v4().to_string();
2948 let (tx, rx) = tokio::sync::oneshot::channel();
2949
2950 {
2951 let mut pending = self.state.pending_evals.lock().await;
2952 if pending.len() >= MAX_PENDING_EVALS {
2953 return Err(format!(
2954 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
2955 ));
2956 }
2957 pending.insert(id.clone(), tx);
2958 }
2959
2960 let code = code.trim();
2964 let needs_return = !code.starts_with("return ")
2965 && !code.starts_with("return;")
2966 && !code.starts_with('{')
2967 && !code.starts_with("if ")
2968 && !code.starts_with("if(")
2969 && !code.starts_with("for ")
2970 && !code.starts_with("for(")
2971 && !code.starts_with("while ")
2972 && !code.starts_with("while(")
2973 && !code.starts_with("switch ")
2974 && !code.starts_with("try ")
2975 && !code.starts_with("const ")
2976 && !code.starts_with("let ")
2977 && !code.starts_with("var ")
2978 && !code.starts_with("function ")
2979 && !code.starts_with("class ")
2980 && !code.starts_with("throw ");
2981 let code = if needs_return {
2982 format!("return {code}")
2983 } else {
2984 code.to_string()
2985 };
2986
2987 let id_js = js_string(&id);
2988 let inject = format!(
2989 r"
2990 (async () => {{
2991 try {{
2992 const __result = await (async () => {{ {code} }})();
2993 const __type = __result === undefined ? 'undefined'
2994 : __result === null ? 'null' : 'value';
2995 const __val = __type === 'undefined' ? null
2996 : __type === 'null' ? null : __result;
2997 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2998 id: {id_js},
2999 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3000 }});
3001 }} catch (e) {{
3002 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3003 id: {id_js},
3004 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3005 }});
3006 }}
3007 }})();
3008 "
3009 );
3010
3011 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3012 self.state.pending_evals.lock().await.remove(&id);
3013 return Err(format!("eval injection failed: {e}"));
3014 }
3015
3016 match tokio::time::timeout(timeout, rx).await {
3017 Ok(Ok(raw)) => {
3018 self.check_bridge_version_once();
3019 if raw.len() > MAX_EVAL_RESULT_LEN {
3020 return Err(format!(
3021 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3022 raw.len()
3023 ));
3024 }
3025 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
3026 if let Some(err) = envelope.get("__victauri_err") {
3027 return Err(format!(
3028 "JavaScript error: {}",
3029 err.as_str().unwrap_or("unknown error")
3030 ));
3031 }
3032 if envelope.get("__victauri_ok").is_some() {
3033 let js_type = envelope
3034 .get("__victauri_type")
3035 .and_then(|t| t.as_str())
3036 .unwrap_or("value");
3037 return match js_type {
3038 "undefined" => Ok("undefined".to_string()),
3039 "null" => Ok("null".to_string()),
3040 _ => {
3041 let val = &envelope["__victauri_ok"];
3042 Ok(serde_json::to_string(val)
3043 .unwrap_or_else(|_| "null".to_string()))
3044 }
3045 };
3046 }
3047 }
3048 Ok(raw)
3049 }
3050 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3051 Err(_) => {
3052 self.state.pending_evals.lock().await.remove(&id);
3053 Err(format!("eval timed out after {}s", timeout.as_secs()))
3054 }
3055 }
3056 }
3057
3058 #[cfg(feature = "sqlite")]
3059 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3060 let data_dir = self.bridge.app_data_dir()?;
3061 let path = if let Some(p) = db_path {
3062 data_dir.join(p)
3063 } else {
3064 let mut found = None;
3065 if let Ok(entries) = std::fs::read_dir(&data_dir) {
3066 for entry in entries.flatten() {
3067 let p = entry.path();
3068 if p.extension()
3069 .is_some_and(|ext| ext == "db" || ext == "sqlite" || ext == "sqlite3")
3070 {
3071 found = Some(p);
3072 break;
3073 }
3074 }
3075 }
3076 found.ok_or_else(|| "no database found in app data directory".to_string())?
3077 };
3078 Self::safe_within(&data_dir, &path)?;
3079
3080 let path_str = path
3081 .to_str()
3082 .ok_or_else(|| "invalid path encoding".to_string())?
3083 .to_string();
3084
3085 tokio::task::spawn_blocking(move || {
3086 let conn = rusqlite::Connection::open_with_flags(
3087 &path_str,
3088 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3089 )
3090 .map_err(|e| format!("cannot open database: {e}"))?;
3091
3092 let journal_mode: String = conn
3093 .pragma_query_value(None, "journal_mode", |r| r.get(0))
3094 .unwrap_or_else(|_| "unknown".to_string());
3095
3096 let page_count: i64 = conn
3097 .pragma_query_value(None, "page_count", |r| r.get(0))
3098 .unwrap_or(0);
3099
3100 let page_size: i64 = conn
3101 .pragma_query_value(None, "page_size", |r| r.get(0))
3102 .unwrap_or(0);
3103
3104 let freelist_count: i64 = conn
3105 .pragma_query_value(None, "freelist_count", |r| r.get(0))
3106 .unwrap_or(0);
3107
3108 let wal_checkpoint: String = if journal_mode == "wal" {
3109 let mut info = String::from("n/a");
3110 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3111 let busy: i64 = r.get(0)?;
3112 let checkpointed: i64 = r.get(1)?;
3113 let total: i64 = r.get(2)?;
3114 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3115 Ok(())
3116 });
3117 info
3118 } else {
3119 "n/a (not WAL mode)".to_string()
3120 };
3121
3122 let integrity: String = conn
3123 .pragma_query_value(None, "quick_check", |r| r.get(0))
3124 .unwrap_or_else(|_| "failed".to_string());
3125
3126 let db_size_bytes = page_count * page_size;
3127 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3128
3129 let mut tables = Vec::new();
3130 if let Ok(mut stmt) =
3131 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3132 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3133 {
3134 for name in rows.flatten() {
3135 let count: i64 = conn
3136 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3137 .unwrap_or(0);
3138 tables.push(serde_json::json!({
3139 "name": name,
3140 "row_count": count,
3141 }));
3142 }
3143 }
3144
3145 Ok(serde_json::json!({
3146 "database": path_str,
3147 "journal_mode": journal_mode,
3148 "page_count": page_count,
3149 "page_size": page_size,
3150 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3151 "freelist_count": freelist_count,
3152 "wal_checkpoint": wal_checkpoint,
3153 "integrity_check": integrity,
3154 "tables": tables,
3155 }))
3156 })
3157 .await
3158 .map_err(|e| format!("db health task failed: {e}"))?
3159 }
3160
3161 fn check_bridge_version_once(&self) {
3162 if self.bridge_checked.swap(true, Ordering::Relaxed) {
3163 return;
3164 }
3165 let handler = self.clone();
3166 tokio::spawn(async move {
3167 match handler
3168 .eval_with_return_timeout(
3169 "window.__VICTAURI__?.version",
3170 None,
3171 std::time::Duration::from_secs(5),
3172 )
3173 .await
3174 {
3175 Ok(v) => {
3176 let v = v.trim_matches('"');
3177 if v == BRIDGE_VERSION {
3178 tracing::debug!("Bridge version verified: {v}");
3179 } else {
3180 tracing::warn!(
3181 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3182 );
3183 }
3184 }
3185 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3186 }
3187 });
3188 }
3189}
3190
3191const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3192It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3193(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3194(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3195\n\nBACKEND tools (direct Rust access, no webview needed): \
3196'app_info' (app config, directory paths, discovered databases, process info), \
3197'list_app_dir' (browse app data/config/log directories), \
3198'read_app_file' (read files from app directories), \
3199'query_db' (read-only SQLite queries with auto-discovery). \
3200\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3201'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3202capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3203Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3204capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3205task tracking, and automatic Tauri event bus monitoring. \
3206'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3207drops, and response corruption into Tauri commands at the Rust layer. \
3208'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3209activity across IPC + DOM + console + network + window events into a coherent narrative. \
3210\n\nWEBVIEW tools: \
3211'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3212'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3213'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3214\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3215\n\nCOMPOUND tools with an 'action' parameter: \
3216'window' (get_state, list, manage, resize, move_to, set_title), \
3217'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3218set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3219get_events, events_between, get_replay, export, import, replay), \
3220'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3221\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3222get_memory_stats, get_plugin_info, get_diagnostics.";
3223
3224impl ServerHandler for VictauriMcpHandler {
3225 fn get_info(&self) -> ServerInfo {
3226 ServerInfo::new(
3227 ServerCapabilities::builder()
3228 .enable_tools()
3229 .enable_resources()
3230 .enable_resources_subscribe()
3231 .build(),
3232 )
3233 .with_instructions(SERVER_INSTRUCTIONS)
3234 }
3235
3236 async fn list_tools(
3237 &self,
3238 _request: Option<PaginatedRequestParams>,
3239 _context: RequestContext<RoleServer>,
3240 ) -> Result<ListToolsResult, ErrorData> {
3241 let all_tools = Self::tool_router().list_all();
3242 let filtered: Vec<Tool> = all_tools
3243 .into_iter()
3244 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3245 .collect();
3246 Ok(ListToolsResult {
3247 tools: filtered,
3248 ..Default::default()
3249 })
3250 }
3251
3252 async fn call_tool(
3253 &self,
3254 request: CallToolRequestParams,
3255 context: RequestContext<RoleServer>,
3256 ) -> Result<CallToolResult, ErrorData> {
3257 let tool_name: String = request.name.as_ref().to_owned();
3258 if !self.state.privacy.is_tool_enabled(&tool_name) {
3259 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3260 return Ok(tool_disabled(&tool_name));
3261 }
3262 self.state
3263 .tool_invocations
3264 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3265 let start = std::time::Instant::now();
3266 tracing::debug!(tool = %tool_name, "tool invocation started");
3267 let ctx = ToolCallContext::new(self, request, context);
3268 let result = Self::tool_router().call(ctx).await;
3269 let elapsed = start.elapsed();
3270 tracing::debug!(
3271 tool = %tool_name,
3272 elapsed_ms = elapsed.as_millis() as u64,
3273 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3274 "tool invocation completed"
3275 );
3276
3277 if self.state.privacy.redaction_enabled {
3280 result.map(|mut r| {
3281 for item in &mut r.content {
3282 if let RawContent::Text(ref mut tc) = item.raw {
3283 tc.text = self.state.privacy.redact_output(&tc.text);
3284 }
3285 }
3286 r
3287 })
3288 } else {
3289 result
3290 }
3291 }
3292
3293 fn get_tool(&self, name: &str) -> Option<Tool> {
3294 if !self.state.privacy.is_tool_enabled(name) {
3295 return None;
3296 }
3297 Self::tool_router().get(name).cloned()
3298 }
3299
3300 async fn list_resources(
3301 &self,
3302 _request: Option<PaginatedRequestParams>,
3303 _context: RequestContext<RoleServer>,
3304 ) -> Result<ListResourcesResult, ErrorData> {
3305 Ok(ListResourcesResult {
3306 resources: vec![
3307 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3308 .with_description(
3309 "Live IPC call log — all commands invoked between frontend and backend",
3310 )
3311 .with_mime_type("application/json")
3312 .no_annotation(),
3313 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3314 .with_description(
3315 "Current state of all Tauri windows — position, size, visibility, focus",
3316 )
3317 .with_mime_type("application/json")
3318 .no_annotation(),
3319 RawResource::new(RESOURCE_URI_STATE, "state")
3320 .with_description(
3321 "Victauri plugin state — event count, registered commands, memory stats",
3322 )
3323 .with_mime_type("application/json")
3324 .no_annotation(),
3325 ],
3326 ..Default::default()
3327 })
3328 }
3329
3330 async fn read_resource(
3331 &self,
3332 request: ReadResourceRequestParams,
3333 _context: RequestContext<RoleServer>,
3334 ) -> Result<ReadResourceResult, ErrorData> {
3335 let uri = &request.uri;
3336 let json = match uri.as_str() {
3337 RESOURCE_URI_IPC_LOG => {
3338 if let Ok(json) = self
3339 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3340 .await
3341 {
3342 json
3343 } else {
3344 let calls = self.state.event_log.ipc_calls();
3345 serde_json::to_string_pretty(&calls)
3346 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3347 }
3348 }
3349 RESOURCE_URI_WINDOWS => {
3350 let states = self.bridge.get_window_states(None);
3351 serde_json::to_string_pretty(&states)
3352 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3353 }
3354 RESOURCE_URI_STATE => {
3355 let state_json = serde_json::json!({
3356 "events_captured": self.state.event_log.len(),
3357 "commands_registered": self.state.registry.count(),
3358 "memory": crate::memory::current_stats(),
3359 "port": self.state.port.load(Ordering::Relaxed),
3360 });
3361 serde_json::to_string_pretty(&state_json)
3362 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3363 }
3364 _ => {
3365 return Err(ErrorData::resource_not_found(
3366 format!("unknown resource: {uri}"),
3367 None,
3368 ));
3369 }
3370 };
3371
3372 let json = if self.state.privacy.redaction_enabled {
3373 self.state.privacy.redact_output(&json)
3374 } else {
3375 json
3376 };
3377
3378 Ok(ReadResourceResult::new(vec![ResourceContents::text(
3379 json, uri,
3380 )]))
3381 }
3382
3383 async fn subscribe(
3384 &self,
3385 request: SubscribeRequestParams,
3386 _context: RequestContext<RoleServer>,
3387 ) -> Result<(), ErrorData> {
3388 let uri = &request.uri;
3389 match uri.as_str() {
3390 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3391 self.subscriptions.lock().await.insert(uri.clone());
3392 tracing::info!("Client subscribed to resource: {uri}");
3393 Ok(())
3394 }
3395 _ => Err(ErrorData::resource_not_found(
3396 format!("unknown resource: {uri}"),
3397 None,
3398 )),
3399 }
3400 }
3401
3402 async fn unsubscribe(
3403 &self,
3404 request: UnsubscribeRequestParams,
3405 _context: RequestContext<RoleServer>,
3406 ) -> Result<(), ErrorData> {
3407 self.subscriptions.lock().await.remove(&request.uri);
3408 tracing::info!("Client unsubscribed from resource: {}", request.uri);
3409 Ok(())
3410 }
3411}
3412
3413#[cfg(test)]
3414mod tests {
3415 use super::*;
3416
3417 #[test]
3418 fn js_string_simple() {
3419 assert_eq!(js_string("hello"), "\"hello\"");
3420 }
3421
3422 #[test]
3423 fn js_string_single_quotes() {
3424 let result = js_string("it's a test");
3425 assert!(result.contains("it's a test"));
3426 }
3427
3428 #[test]
3429 fn js_string_double_quotes() {
3430 let result = js_string(r#"say "hello""#);
3431 assert!(result.contains(r#"\""#));
3432 }
3433
3434 #[test]
3435 fn js_string_backslashes() {
3436 let result = js_string(r"path\to\file");
3437 assert!(result.contains(r"\\"));
3438 }
3439
3440 #[test]
3441 fn js_string_newlines_and_tabs() {
3442 let result = js_string("line1\nline2\ttab");
3443 assert!(result.contains(r"\n"));
3444 assert!(result.contains(r"\t"));
3445 assert!(!result.contains('\n'));
3446 }
3447
3448 #[test]
3449 fn js_string_null_bytes() {
3450 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
3451 let result = js_string(&input);
3452 assert!(result.contains("\\u0000"));
3454 assert!(!result.contains('\0'));
3455 }
3456
3457 #[test]
3458 fn js_string_template_literal_injection() {
3459 let result = js_string("`${alert(1)}`");
3460 assert!(result.starts_with('"'));
3463 assert!(result.ends_with('"'));
3464 }
3465
3466 #[test]
3467 fn js_string_unicode_separators() {
3468 let result = js_string("a\u{2028}b\u{2029}c");
3473 let decoded: String = serde_json::from_str(&result).unwrap();
3475 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
3476 }
3477
3478 #[test]
3479 fn js_string_empty() {
3480 assert_eq!(js_string(""), "\"\"");
3481 }
3482
3483 #[test]
3484 fn js_string_html_script_close() {
3485 let result = js_string("</script><img onerror=alert(1)>");
3487 assert!(result.starts_with('"'));
3488 let decoded: String = serde_json::from_str(&result).unwrap();
3490 assert_eq!(decoded, "</script><img onerror=alert(1)>");
3491 }
3492
3493 #[test]
3494 fn js_string_very_long() {
3495 let long = "a".repeat(100_000);
3496 let result = js_string(&long);
3497 assert!(result.len() >= 100_002); }
3499
3500 #[test]
3503 fn url_allows_http() {
3504 assert!(validate_url("http://example.com", false).is_ok());
3505 }
3506
3507 #[test]
3508 fn url_allows_https() {
3509 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
3510 }
3511
3512 #[test]
3513 fn url_allows_http_localhost() {
3514 assert!(validate_url("http://localhost:3000", false).is_ok());
3515 }
3516
3517 #[test]
3518 fn url_blocks_file_by_default() {
3519 let err = validate_url("file:///etc/passwd", false).unwrap_err();
3520 assert!(err.contains("file"), "error should mention the file scheme");
3521 }
3522
3523 #[test]
3524 fn url_allows_file_when_opted_in() {
3525 assert!(validate_url("file:///tmp/test.html", true).is_ok());
3526 }
3527
3528 #[test]
3529 fn url_blocks_javascript() {
3530 assert!(validate_url("javascript:alert(1)", false).is_err());
3531 }
3532
3533 #[test]
3534 fn url_blocks_javascript_case_insensitive() {
3535 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
3536 }
3537
3538 #[test]
3539 fn url_blocks_data_scheme() {
3540 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
3541 }
3542
3543 #[test]
3544 fn url_blocks_vbscript() {
3545 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
3546 }
3547
3548 #[test]
3549 fn url_rejects_invalid() {
3550 assert!(validate_url("not a url at all", false).is_err());
3551 }
3552
3553 #[test]
3554 fn url_strips_control_chars() {
3555 let input = format!("http://example{}com", '\0');
3557 assert!(validate_url(&input, false).is_ok());
3558 }
3559
3560 #[test]
3563 fn css_color_valid_hex() {
3564 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
3565 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
3566 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
3567 }
3568
3569 #[test]
3570 fn css_color_valid_rgb() {
3571 assert_eq!(
3572 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
3573 "rgb(255, 0, 0)"
3574 );
3575 assert_eq!(
3576 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
3577 "rgba(0, 0, 0, 0.5)"
3578 );
3579 }
3580
3581 #[test]
3582 fn css_color_valid_named() {
3583 assert_eq!(sanitize_css_color("red").unwrap(), "red");
3584 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
3585 }
3586
3587 #[test]
3588 fn css_color_valid_hsl() {
3589 assert_eq!(
3590 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
3591 "hsl(120, 50%, 50%)"
3592 );
3593 }
3594
3595 #[test]
3596 fn css_color_rejects_too_long() {
3597 let long = "a".repeat(101);
3598 assert!(sanitize_css_color(&long).is_err());
3599 }
3600
3601 #[test]
3602 fn css_color_rejects_backslash_escapes() {
3603 assert!(sanitize_css_color(r"red\00").is_err());
3604 assert!(sanitize_css_color(r"\72\65\64").is_err());
3605 }
3606
3607 #[test]
3608 fn css_color_rejects_url_injection() {
3609 assert!(sanitize_css_color("url(http://evil.com)").is_err());
3610 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
3611 }
3612
3613 #[test]
3614 fn css_color_rejects_expression_injection() {
3615 assert!(sanitize_css_color("expression(alert(1))").is_err());
3616 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
3617 }
3618
3619 #[test]
3620 fn css_color_rejects_import() {
3621 assert!(sanitize_css_color("@import url(evil.css)").is_err());
3622 }
3623
3624 #[test]
3625 fn css_color_rejects_semicolons_and_braces() {
3626 assert!(sanitize_css_color("red; background: url(evil)").is_err());
3627 assert!(sanitize_css_color("red} body { color: blue").is_err());
3628 }
3629
3630 #[test]
3631 fn css_color_rejects_special_chars() {
3632 assert!(sanitize_css_color("red<script>").is_err());
3633 assert!(sanitize_css_color("red\"onload=alert").is_err());
3634 assert!(sanitize_css_color("red'onclick=alert").is_err());
3635 }
3636
3637 #[test]
3638 fn css_color_trims_whitespace() {
3639 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
3640 }
3641
3642 #[test]
3643 fn css_color_empty_string() {
3644 assert_eq!(sanitize_css_color("").unwrap(), "");
3645 }
3646}