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