Skip to main content

victauri_plugin/mcp/
mod.rs

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