Skip to main content

victauri_plugin/mcp/
mod.rs

1// This file is intentionally large (~3,400 lines). rmcp's `#[tool_router]`
2// macro requires every `#[tool]` method to live in a single `impl` block, so
3// splitting the handler across files would break tool registration. Parameter
4// structs are already factored into sub-modules (webview_params, window_params,
5// etc.) to keep this file focused on dispatch logic.
6
7mod backend_params;
8mod compound_params;
9mod helpers;
10mod introspection_params;
11mod other_params;
12mod rest;
13mod server;
14mod verification_params;
15mod webview_params;
16mod window_params;
17
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use rmcp::handler::server::tool::ToolCallContext;
23use rmcp::handler::server::wrapper::Parameters;
24use rmcp::model::{
25    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
26    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
27    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
28    Tool, UnsubscribeRequestParams,
29};
30use rmcp::service::RequestContext;
31use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
32use tokio::sync::Mutex;
33
34use crate::VictauriState;
35use crate::bridge::WebviewBridge;
36
37use helpers::{
38    RecoveryHint, js_string, json_result, missing_param, sanitize_css_color, tool_disabled,
39    tool_error, tool_error_with_hint, validate_url,
40};
41
42pub use backend_params::*;
43pub use compound_params::*;
44pub use introspection_params::*;
45pub use other_params::{
46    DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
47    WaitCondition, WaitForParams,
48};
49pub use server::*;
50pub use verification_params::*;
51pub use webview_params::*;
52pub use window_params::*;
53
54// ── MCP Handler ──────────────────────────────────────────────────────────────
55
56/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
57/// growth of the `pending_evals` map if callbacks are never resolved.
58pub(crate) const MAX_PENDING_EVALS: usize = 100;
59
60fn chrono_now() -> String {
61    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
62}
63
64/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
65const MAX_EVAL_CODE_LEN: usize = 1_000_000;
66
67/// Maximum length of a JavaScript eval return value (5 MB).
68/// Results exceeding this are truncated to prevent memory exhaustion.
69const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
70
71/// Default number of entries returned by IPC/network log tools when no explicit
72/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
73const DEFAULT_LOG_LIMIT: usize = 100;
74
75/// Per-field byte cap applied to each IPC/network log entry before serialization.
76/// Large request/response bodies are truncated with a marker so the aggregate
77/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
78const MAX_LOG_FIELD_BYTES: usize = 4096;
79
80const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
81const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
82const RESOURCE_URI_STATE: &str = "victauri://state";
83
84const BRIDGE_VERSION: &str = "0.5.0";
85
86const SAFE_ENV_PREFIXES: &[&str] = &[
87    "HOME",
88    "USER",
89    "LANG",
90    "LC_",
91    "TERM",
92    "SHELL",
93    "DISPLAY",
94    "XDG_",
95    "TAURI_",
96    "VICTAURI_",
97    "NODE_ENV",
98    "OS",
99    "HOSTNAME",
100    "PWD",
101    "SHLVL",
102    "LOGNAME",
103];
104
105/// MCP tool handler that dispatches tool calls to the webview bridge and state.
106#[derive(Clone)]
107pub struct VictauriMcpHandler {
108    state: Arc<VictauriState>,
109    bridge: Arc<dyn WebviewBridge>,
110    subscriptions: Arc<Mutex<HashSet<String>>>,
111    bridge_checked: Arc<AtomicBool>,
112    probed_labels: Arc<Mutex<HashSet<String>>>,
113    /// Window keys whose previous eval timed out. The next eval on such a window
114    /// does a fast liveness probe so a reloaded/crashed bridge fails fast with a
115    /// clear error instead of blocking the full timeout again.
116    timed_out_labels: Arc<Mutex<HashSet<String>>>,
117}
118
119#[tool_router]
120impl VictauriMcpHandler {
121    // ── Standalone Tools ────────────────────────────────────────────────────
122
123    #[tool(
124        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
125        annotations(
126            read_only_hint = false,
127            destructive_hint = true,
128            idempotent_hint = false,
129            open_world_hint = false
130        )
131    )]
132    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
133        if !self.state.privacy.is_tool_enabled("eval_js") {
134            return tool_disabled("eval_js");
135        }
136        if params.code.len() > MAX_EVAL_CODE_LEN {
137            return tool_error("code exceeds maximum length (1 MB)");
138        }
139        match self
140            .eval_with_return(&params.code, params.webview_label.as_deref())
141            .await
142        {
143            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
144            Err(e) => tool_error(e),
145        }
146    }
147
148    #[tool(
149        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).",
150        annotations(
151            read_only_hint = true,
152            destructive_hint = false,
153            idempotent_hint = true,
154            open_world_hint = false
155        )
156    )]
157    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
158        let format = params.format.unwrap_or(SnapshotFormat::Compact);
159        let format_str = match format {
160            SnapshotFormat::Compact => "compact",
161            SnapshotFormat::Json => "json",
162        };
163        let code = format!(
164            "return window.__VICTAURI__?.snapshot({})",
165            js_string(format_str)
166        );
167        self.eval_bridge(&code, params.webview_label.as_deref())
168            .await
169    }
170
171    #[tool(
172        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.",
173        annotations(
174            read_only_hint = true,
175            destructive_hint = false,
176            idempotent_hint = true,
177            open_world_hint = false
178        )
179    )]
180    async fn find_elements(
181        &self,
182        Parameters(params): Parameters<FindElementsParams>,
183    ) -> CallToolResult {
184        let mut parts: Vec<String> = Vec::new();
185        if let Some(t) = &params.text {
186            parts.push(format!("text: {}", js_string(t)));
187        }
188        if let Some(r) = &params.role {
189            parts.push(format!("role: {}", js_string(r)));
190        }
191        if let Some(tid) = &params.test_id {
192            parts.push(format!("test_id: {}", js_string(tid)));
193        }
194        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
195            parts.push(format!("css: {}", js_string(c)));
196        }
197        if let Some(n) = &params.name {
198            parts.push(format!("name: {}", js_string(n)));
199        }
200        if let Some(max) = params.max_results {
201            parts.push(format!("max_results: {max}"));
202        }
203        if let Some(t) = &params.tag {
204            parts.push(format!("tag: {}", js_string(t)));
205        }
206        if let Some(p) = &params.placeholder {
207            parts.push(format!("placeholder: {}", js_string(p)));
208        }
209        if let Some(a) = &params.alt {
210            parts.push(format!("alt: {}", js_string(a)));
211        }
212        if let Some(ta) = &params.title_attr {
213            parts.push(format!("title_attr: {}", js_string(ta)));
214        }
215        if let Some(l) = &params.label {
216            parts.push(format!("label: {}", js_string(l)));
217        }
218        if let Some(true) = params.exact {
219            parts.push("exact: true".to_string());
220        }
221        if let Some(e) = params.enabled {
222            parts.push(format!("enabled: {e}"));
223        }
224        let code = format!(
225            "return window.__VICTAURI__?.findElements({{ {} }})",
226            parts.join(", ")
227        );
228        match self
229            .eval_with_return(&code, params.webview_label.as_deref())
230            .await
231        {
232            Ok(result) => {
233                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
234                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
235                {
236                    return tool_error(err);
237                }
238                CallToolResult::success(vec![Content::text(result)])
239            }
240            Err(e) => tool_error(e),
241        }
242    }
243
244    #[tool(
245        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.",
246        annotations(
247            read_only_hint = false,
248            destructive_hint = true,
249            idempotent_hint = false,
250            open_world_hint = false
251        )
252    )]
253    async fn invoke_command(
254        &self,
255        Parameters(params): Parameters<InvokeCommandParams>,
256    ) -> CallToolResult {
257        if !self.state.privacy.is_invoke_allowed(&params.command) {
258            return tool_disabled("invoke_command");
259        }
260        if !self.state.privacy.is_command_allowed(&params.command) {
261            return tool_error(format!(
262                "command '{}' is blocked by privacy configuration",
263                params.command
264            ));
265        }
266
267        // ── Fault injection check ──
268        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
269            match fault {
270                crate::introspection::FaultType::Delay { delay_ms } => {
271                    tracing::info!(
272                        command = %params.command,
273                        delay_ms = delay_ms,
274                        "fault injection: delaying command"
275                    );
276                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
277                    // After delay, continue with normal execution below
278                }
279                crate::introspection::FaultType::Error { ref message } => {
280                    tracing::info!(
281                        command = %params.command,
282                        "fault injection: returning error"
283                    );
284                    return tool_error(format!(
285                        "[FAULT INJECTED] command '{}': {message}",
286                        params.command
287                    ));
288                }
289                crate::introspection::FaultType::Drop => {
290                    tracing::info!(
291                        command = %params.command,
292                        "fault injection: dropping response"
293                    );
294                    return CallToolResult::success(vec![Content::text("{}")]);
295                }
296                crate::introspection::FaultType::Corrupt => {
297                    tracing::info!(
298                        command = %params.command,
299                        "fault injection: corrupting response"
300                    );
301                    // Execute normally but mangle the response
302                    let args_json = params.args.unwrap_or(serde_json::json!({}));
303                    let args_str =
304                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
305                    let code = format!(
306                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
307                        js_string(&params.command)
308                    );
309                    if let Ok(result) = self
310                        .eval_with_return(&code, params.webview_label.as_deref())
311                        .await
312                    {
313                        let corrupted = format!(
314                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
315                            result.len()
316                        );
317                        return CallToolResult::success(vec![Content::text(corrupted)]);
318                    }
319                    return CallToolResult::success(vec![Content::text(
320                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
321                    )]);
322                }
323            }
324        }
325
326        // ── Normal execution with timing ──
327        let start = std::time::Instant::now();
328        let args_json = params.args.unwrap_or(serde_json::json!({}));
329        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
330        let code = format!(
331            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
332            js_string(&params.command)
333        );
334        let result = self
335            .eval_with_return(&code, params.webview_label.as_deref())
336            .await;
337        let elapsed = start.elapsed();
338        self.state.command_timings.record(&params.command, elapsed);
339
340        match result {
341            Ok(result) => {
342                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
343                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
344                {
345                    return tool_error(format!(
346                        "command '{}' returned error: {err}",
347                        params.command
348                    ));
349                }
350                CallToolResult::success(vec![Content::text(result)])
351            }
352            Err(e) => tool_error(format!("invoke_command failed: {e}")),
353        }
354    }
355
356    #[tool(
357        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
358        annotations(
359            read_only_hint = true,
360            destructive_hint = false,
361            idempotent_hint = true,
362            open_world_hint = false
363        )
364    )]
365    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
366        self.track_tool_call();
367        if !self.state.privacy.is_tool_enabled("screenshot") {
368            return tool_disabled("screenshot");
369        }
370        match self
371            .bridge
372            .get_native_handle(params.window_label.as_deref())
373        {
374            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
375                Ok(png_bytes) => {
376                    use base64::Engine;
377                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
378                    CallToolResult::success(vec![Content::image(b64, "image/png")])
379                }
380                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
381            },
382            Err(e) => tool_error(format!("cannot get window handle: {e}")),
383        }
384    }
385
386    #[tool(
387        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
388        annotations(
389            read_only_hint = true,
390            destructive_hint = false,
391            idempotent_hint = true,
392            open_world_hint = false
393        )
394    )]
395    async fn verify_state(
396        &self,
397        Parameters(params): Parameters<VerifyStateParams>,
398    ) -> CallToolResult {
399        if !self.state.privacy.is_tool_enabled("eval_js") {
400            return tool_disabled("verify_state requires eval_js capability");
401        }
402        let code = format!("return ({})", params.frontend_expr);
403        let frontend_json = match self
404            .eval_with_return(&code, params.webview_label.as_deref())
405            .await
406        {
407            Ok(result) => result,
408            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
409        };
410
411        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
412            Ok(v) => v,
413            Err(e) => {
414                return tool_error(format!(
415                    "frontend expression did not return valid JSON: {e}"
416                ));
417            }
418        };
419
420        let backend_state = if let Some(state) = params.backend_state {
421            state
422        } else if let Some(ref cmd) = params.backend_command {
423            if !self.state.privacy.is_command_allowed(cmd) {
424                return tool_error(format!(
425                    "command '{cmd}' is blocked by privacy configuration"
426                ));
427            }
428            let args = params.backend_args.unwrap_or(serde_json::json!({}));
429            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
430            let invoke_code = format!(
431                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
432                js_string(cmd)
433            );
434            match self
435                .eval_with_return(&invoke_code, params.webview_label.as_deref())
436                .await
437            {
438                Ok(result) => match serde_json::from_str(&result) {
439                    Ok(v) => v,
440                    Err(e) => {
441                        return tool_error(format!(
442                            "backend command '{cmd}' did not return valid JSON: {e}"
443                        ));
444                    }
445                },
446                Err(e) => {
447                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
448                }
449            }
450        } else {
451            return tool_error("either backend_state or backend_command must be provided");
452        };
453
454        let result = victauri_core::verify_state(frontend_state, backend_state);
455        json_result(&result)
456    }
457
458    #[tool(
459        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.",
460        annotations(
461            read_only_hint = true,
462            destructive_hint = false,
463            idempotent_hint = true,
464            open_world_hint = false
465        )
466    )]
467    async fn detect_ghost_commands(
468        &self,
469        Parameters(params): Parameters<GhostCommandParams>,
470    ) -> CallToolResult {
471        // Project only command names in JS — ghost detection never needs request
472        // or response bodies, so this stays tiny even when the full IPC log is
473        // huge (avoids the eval size cap on busy apps).
474        let code = "return (window.__VICTAURI__?.getIpcLog() || []).map(function(c){ return (c && c.command) || null; }).filter(function(x){ return x; })";
475        let ipc_json = match self
476            .eval_with_return(code, params.webview_label.as_deref())
477            .await
478        {
479            Ok(r) => r,
480            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
481        };
482
483        let command_names: Vec<String> = match serde_json::from_str(&ipc_json) {
484            Ok(v) => v,
485            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
486        };
487        let frontend_commands: Vec<String> = command_names
488            .into_iter()
489            .collect::<std::collections::HashSet<_>>()
490            .into_iter()
491            .collect();
492
493        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
494        json_result(&report)
495    }
496
497    #[tool(
498        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
499        annotations(
500            read_only_hint = true,
501            destructive_hint = false,
502            idempotent_hint = true,
503            open_world_hint = false
504        )
505    )]
506    async fn check_ipc_integrity(
507        &self,
508        Parameters(params): Parameters<IpcIntegrityParams>,
509    ) -> CallToolResult {
510        let threshold = params.stale_threshold_ms.unwrap_or(5000);
511        let code = format!(
512            r"return (function() {{
513                var log = window.__VICTAURI__?.getIpcLog() || [];
514                var now = Date.now();
515                var threshold = {threshold};
516                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
517                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
518                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
519                var net = window.__VICTAURI__?.getNetworkLog() || [];
520                var warning = null;
521                if (log.length === 0 && net.length > 5) {{
522                    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.';
523                }}
524                return {{
525                    healthy: stale.length === 0 && errored.length === 0,
526                    total_calls: log.length,
527                    pending_count: pending.length,
528                    stale_count: stale.length,
529                    error_count: errored.length,
530                    stale_calls: stale.slice(0, 20),
531                    errored_calls: errored.slice(0, 20),
532                    warning: warning
533                }};
534            }})()"
535        );
536        self.eval_bridge(&code, params.webview_label.as_deref())
537            .await
538    }
539
540    #[tool(
541        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).",
542        annotations(
543            read_only_hint = true,
544            destructive_hint = false,
545            idempotent_hint = true,
546            open_world_hint = false
547        )
548    )]
549    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
550        let value = params
551            .value
552            .as_ref()
553            .map_or_else(|| "null".to_string(), |v| js_string(v));
554        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
555        let poll = params.poll_ms.unwrap_or(200);
556        let code = format!(
557            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
558            js_string(params.condition.as_str())
559        );
560        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
561        match self
562            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
563            .await
564        {
565            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
566            Err(e) => tool_error(e),
567        }
568    }
569
570    #[tool(
571        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.",
572        annotations(
573            read_only_hint = true,
574            destructive_hint = false,
575            idempotent_hint = true,
576            open_world_hint = false
577        )
578    )]
579    async fn assert_semantic(
580        &self,
581        Parameters(params): Parameters<SemanticAssertParams>,
582    ) -> CallToolResult {
583        if !self.state.privacy.is_tool_enabled("eval_js") {
584            return tool_disabled("assert_semantic requires eval_js capability");
585        }
586        let code = format!("return ({})", params.expression);
587        let actual_json = match self
588            .eval_with_return(&code, params.webview_label.as_deref())
589            .await
590        {
591            Ok(result) => result,
592            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
593        };
594
595        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
596            Ok(v) => v,
597            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
598        };
599
600        let assertion = victauri_core::SemanticAssertion {
601            label: params.label,
602            condition: params.condition,
603            expected: params.expected,
604        };
605
606        let result = victauri_core::evaluate_assertion(actual, &assertion);
607        json_result(&result)
608    }
609
610    #[tool(
611        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
612        annotations(
613            read_only_hint = true,
614            destructive_hint = false,
615            idempotent_hint = true,
616            open_world_hint = false
617        )
618    )]
619    async fn resolve_command(
620        &self,
621        Parameters(params): Parameters<ResolveCommandParams>,
622    ) -> CallToolResult {
623        self.track_tool_call();
624        let limit = params.limit.unwrap_or(5);
625        let mut results = self.state.registry.resolve(&params.query);
626        results.truncate(limit);
627        json_result(&results)
628    }
629
630    #[tool(
631        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.",
632        annotations(
633            read_only_hint = true,
634            destructive_hint = false,
635            idempotent_hint = true,
636            open_world_hint = false
637        )
638    )]
639    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
640        self.track_tool_call();
641        let commands = match params.query {
642            Some(q) => self.state.registry.search(&q),
643            None => self.state.registry.list(),
644        };
645        json_result(&commands)
646    }
647
648    #[tool(
649        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.",
650        annotations(
651            read_only_hint = true,
652            destructive_hint = false,
653            idempotent_hint = true,
654            open_world_hint = false
655        )
656    )]
657    async fn get_memory_stats(&self) -> CallToolResult {
658        self.track_tool_call();
659        let stats = crate::memory::current_stats();
660        json_result(&stats)
661    }
662
663    #[tool(
664        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.",
665        annotations(
666            read_only_hint = true,
667            destructive_hint = false,
668            idempotent_hint = true,
669            open_world_hint = false
670        )
671    )]
672    async fn get_plugin_info(&self) -> CallToolResult {
673        self.track_tool_call();
674        let disabled: Vec<&str> = self
675            .state
676            .privacy
677            .disabled_tools
678            .iter()
679            .map(std::string::String::as_str)
680            .collect();
681        let blocklist: Vec<&str> = self
682            .state
683            .privacy
684            .command_blocklist
685            .iter()
686            .map(std::string::String::as_str)
687            .collect();
688        let allowlist: Option<Vec<&str>> = self
689            .state
690            .privacy
691            .command_allowlist
692            .as_ref()
693            .map(|s| s.iter().map(std::string::String::as_str).collect());
694        let all_tools = Self::tool_router().list_all();
695        let enabled_tools: Vec<&str> = all_tools
696            .iter()
697            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
698            .map(|t| t.name.as_ref())
699            .collect();
700
701        let result = serde_json::json!({
702            "version": env!("CARGO_PKG_VERSION"),
703            "bridge_version": BRIDGE_VERSION,
704            "port": self.state.port.load(Ordering::Relaxed),
705            "tools": {
706                "total": all_tools.len(),
707                "enabled": enabled_tools.len(),
708                "enabled_list": enabled_tools,
709                "disabled_list": disabled,
710            },
711            "commands": {
712                "allowlist": allowlist,
713                "blocklist": blocklist,
714            },
715            "privacy": {
716                "profile": self.state.privacy.profile.to_string(),
717                "redaction_enabled": self.state.privacy.redaction_enabled,
718            },
719            "capacities": {
720                "event_log": self.state.event_log.capacity(),
721                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
722            },
723            "registered_commands": self.state.registry.count(),
724            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
725            "uptime_secs": self.state.started_at.elapsed().as_secs(),
726        });
727        json_result(&result)
728    }
729
730    #[tool(
731        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.",
732        annotations(
733            read_only_hint = true,
734            destructive_hint = false,
735            idempotent_hint = true,
736            open_world_hint = false
737        )
738    )]
739    async fn get_diagnostics(
740        &self,
741        Parameters(params): Parameters<DiagnosticsParams>,
742    ) -> CallToolResult {
743        self.eval_bridge(
744            "return window.__VICTAURI__?.getDiagnostics()",
745            params.webview_label.as_deref(),
746        )
747        .await
748    }
749
750    // ── Backend Access Tools ───────────────────────────────────────────────
751
752    #[tool(
753        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.",
754        annotations(
755            read_only_hint = true,
756            destructive_hint = false,
757            idempotent_hint = true,
758            open_world_hint = false
759        )
760    )]
761    async fn app_info(&self) -> CallToolResult {
762        self.track_tool_call();
763        let config = self.bridge.tauri_config();
764
765        let data_dir = self.bridge.app_data_dir().ok();
766        let config_dir = self.bridge.app_config_dir().ok();
767        let log_dir = self.bridge.app_log_dir().ok();
768        let local_data_dir = self.bridge.app_local_data_dir().ok();
769
770        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
771            .filter(|(k, _)| {
772                let upper = k.to_uppercase();
773                SAFE_ENV_PREFIXES
774                    .iter()
775                    .any(|prefix| upper.starts_with(prefix))
776            })
777            .collect();
778
779        #[cfg(feature = "sqlite")]
780        let databases: Vec<String> = data_dir
781            .as_ref()
782            .map(|d| {
783                crate::database::discover_databases(d)
784                    .into_iter()
785                    .filter_map(|p| {
786                        p.strip_prefix(d)
787                            .ok()
788                            .map(|rel| rel.to_string_lossy().into_owned())
789                    })
790                    .collect()
791            })
792            .unwrap_or_default();
793
794        #[cfg(not(feature = "sqlite"))]
795        let databases: Vec<String> = Vec::new();
796
797        let result = serde_json::json!({
798            "config": config,
799            "paths": {
800                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
801                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
802                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
803                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
804            },
805            "databases": databases,
806            "env": env_vars,
807            "process": {
808                "pid": std::process::id(),
809                "arch": std::env::consts::ARCH,
810                "os": std::env::consts::OS,
811                "family": std::env::consts::FAMILY,
812            },
813        });
814        json_result(&result)
815    }
816
817    #[tool(
818        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.",
819        annotations(
820            read_only_hint = true,
821            destructive_hint = false,
822            idempotent_hint = true,
823            open_world_hint = false
824        )
825    )]
826    async fn list_app_dir(
827        &self,
828        Parameters(params): Parameters<ListAppDirParams>,
829    ) -> CallToolResult {
830        self.track_tool_call();
831        let base = match self.resolve_app_dir(params.directory) {
832            Ok(d) => d,
833            Err(e) => return tool_error(e),
834        };
835
836        let target = if let Some(ref sub) = params.path {
837            let resolved = base.join(sub);
838            if !resolved.exists() {
839                return tool_error(format!("directory does not exist: {}", resolved.display()));
840            }
841            if let Err(e) = Self::safe_within(&base, &resolved) {
842                return tool_error(e);
843            }
844            resolved
845        } else {
846            base.clone()
847        };
848
849        if !target.exists() {
850            return tool_error(format!("directory does not exist: {}", target.display()));
851        }
852
853        let max_depth = params.max_depth.unwrap_or(1).min(5);
854        let pattern = params.pattern.as_deref();
855        let mut entries = Vec::new();
856
857        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
858
859        json_result(&serde_json::json!({
860            "base": base.to_string_lossy(),
861            "path": params.path.unwrap_or_default(),
862            "entries": entries,
863            "count": entries.len(),
864        }))
865    }
866
867    #[tool(
868        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.",
869        annotations(
870            read_only_hint = true,
871            destructive_hint = false,
872            idempotent_hint = true,
873            open_world_hint = false
874        )
875    )]
876    async fn read_app_file(
877        &self,
878        Parameters(params): Parameters<ReadAppFileParams>,
879    ) -> CallToolResult {
880        self.track_tool_call();
881        let base = match self.resolve_app_dir(params.directory) {
882            Ok(d) => d,
883            Err(e) => return tool_error(e),
884        };
885
886        let target = base.join(&params.path);
887        if !target.exists() {
888            return tool_error(format!("file not found: {}", params.path));
889        }
890        if let Err(e) = Self::safe_within(&base, &target) {
891            return tool_error(e);
892        }
893        if !target.is_file() {
894            return tool_error(format!("not a file: {}", params.path));
895        }
896
897        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
898        let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
899
900        match std::fs::read(&target) {
901            Ok(mut bytes) => {
902                let original_size = bytes.len();
903                let truncated = bytes.len() > max_bytes;
904                if truncated {
905                    bytes.truncate(max_bytes);
906                }
907
908                let file_info = serde_json::json!({
909                    "path": params.path,
910                    "size": original_size,
911                    "truncated": truncated,
912                    "modified": metadata.as_ref().ok()
913                        .and_then(|m| m.modified().ok())
914                        .map(|t| {
915                            let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
916                            duration.as_secs()
917                        }),
918                });
919
920                if params.binary == Some(true) {
921                    use base64::Engine;
922                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
923                    json_result(&serde_json::json!({
924                        "file": file_info,
925                        "encoding": "base64",
926                        "content": b64,
927                    }))
928                } else {
929                    match String::from_utf8(bytes) {
930                        Ok(text) => json_result(&serde_json::json!({
931                            "file": file_info,
932                            "encoding": "utf-8",
933                            "content": text,
934                        })),
935                        Err(e) => {
936                            use base64::Engine;
937                            let bytes = e.into_bytes();
938                            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
939                            json_result(&serde_json::json!({
940                                "file": file_info,
941                                "encoding": "base64",
942                                "note": "file is not valid UTF-8, returning base64",
943                                "content": b64,
944                            }))
945                        }
946                    }
947                }
948            }
949            Err(e) => tool_error(format!("failed to read file: {e}")),
950        }
951    }
952
953    #[cfg(feature = "sqlite")]
954    #[tool(
955        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.",
956        annotations(
957            read_only_hint = true,
958            destructive_hint = false,
959            idempotent_hint = true,
960            open_world_hint = false
961        )
962    )]
963    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
964        self.track_tool_call();
965        let data_dir = match self.bridge.app_data_dir() {
966            Ok(d) => d,
967            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
968        };
969
970        let app_dirs: Vec<std::path::PathBuf> = [
971            self.bridge.app_data_dir(),
972            self.bridge.app_config_dir(),
973            self.bridge.app_local_data_dir(),
974            self.bridge.app_log_dir(),
975        ]
976        .into_iter()
977        .filter_map(Result::ok)
978        .collect::<std::collections::HashSet<_>>()
979        .into_iter()
980        .collect();
981        // Explicitly-configured roots (VictauriBuilder::db_search_paths) take
982        // precedence over OS app directories for auto-discovery, so a configured
983        // application DB wins over incidental ones (e.g. WebView internals).
984        let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
985        search_dirs.extend(app_dirs);
986
987        let db_path = if let Some(ref rel_path) = params.path {
988            let candidate = std::path::Path::new(rel_path);
989            // Absolute paths are permitted only when they resolve within one of
990            // the allowed roots (app directories or configured db_search_paths).
991            if candidate.is_absolute() {
992                if !candidate.exists() {
993                    return tool_error(format!("database not found: {rel_path}"));
994                }
995                if !search_dirs
996                    .iter()
997                    .any(|d| Self::safe_within(d, candidate).is_ok())
998                {
999                    return tool_error(format!(
1000                        "absolute path '{rel_path}' is not within an allowed directory; \
1001                         register its parent via VictauriBuilder::db_search_paths"
1002                    ));
1003                }
1004            }
1005            let mut found = None;
1006            if candidate.is_absolute() {
1007                found = Some(candidate.to_path_buf());
1008            } else {
1009                for dir in &search_dirs {
1010                    let resolved = dir.join(rel_path);
1011                    if resolved.exists() {
1012                        if let Err(e) = Self::safe_within(dir, &resolved) {
1013                            return tool_error(e);
1014                        }
1015                        found = Some(resolved);
1016                        break;
1017                    }
1018                }
1019            }
1020            if let Some(p) = found {
1021                p
1022            } else {
1023                let dirs_str = search_dirs
1024                    .iter()
1025                    .map(|d| d.display().to_string())
1026                    .collect::<Vec<_>>()
1027                    .join(", ");
1028                return tool_error(format!(
1029                    "database not found: {rel_path} (searched: {dirs_str})"
1030                ));
1031            }
1032        } else {
1033            let mut databases = Vec::new();
1034            for dir in &search_dirs {
1035                databases.extend(crate::database::discover_databases(dir));
1036            }
1037            if let Some(p) = databases.first() {
1038                p.clone()
1039            } else {
1040                let dirs_str = search_dirs
1041                    .iter()
1042                    .map(|d| d.display().to_string())
1043                    .collect::<Vec<_>>()
1044                    .join(", ");
1045                return tool_error(format!("no SQLite databases found in: {dirs_str}"));
1046            }
1047        };
1048
1049        let db_display = db_path
1050            .strip_prefix(&data_dir)
1051            .unwrap_or(&db_path)
1052            .to_string_lossy()
1053            .into_owned();
1054        let bind_params = params.params.unwrap_or_default();
1055
1056        match crate::database::query(&db_path, &params.query, &bind_params, params.max_rows) {
1057            Ok(mut result) => {
1058                if let Some(obj) = result.as_object_mut() {
1059                    obj.insert("database".to_string(), serde_json::json!(db_display));
1060                }
1061                json_result(&result)
1062            }
1063            Err(e) => tool_error(e),
1064        }
1065    }
1066
1067    // ── Compound Tools ──────────────────────────────────────────────────────
1068
1069    #[tool(
1070        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.",
1071        annotations(
1072            read_only_hint = false,
1073            destructive_hint = false,
1074            idempotent_hint = false,
1075            open_world_hint = false
1076        )
1077    )]
1078    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1079        if !self.state.privacy.is_tool_enabled("interact") {
1080            return tool_disabled("interact");
1081        }
1082        match params.action {
1083            InteractAction::Click => {
1084                if !self.state.privacy.is_tool_enabled("interact.click") {
1085                    return tool_disabled("interact.click");
1086                }
1087                let Some(ref_id) = &params.ref_id else {
1088                    return missing_param("ref_id", "click");
1089                };
1090                if params.trusted.unwrap_or(false) {
1091                    // Resolve the element's viewport-center coords, run the
1092                    // actionability check, then deliver a real OS click.
1093                    let probe = format!(
1094                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1095                         if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1096                         var __b=__e.getBoundingClientRect(); \
1097                         return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1098                        js_string(ref_id)
1099                    );
1100                    let raw = match self
1101                        .eval_with_return(&probe, params.webview_label.as_deref())
1102                        .await
1103                    {
1104                        Ok(r) => r,
1105                        Err(e) => return tool_error(e),
1106                    };
1107                    let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1108                        return tool_error_with_hint(
1109                            format!("ref not found: {ref_id}"),
1110                            RecoveryHint::CheckInput,
1111                        );
1112                    };
1113                    let (Some(x), Some(y)) = (
1114                        point.get("x").and_then(serde_json::Value::as_f64),
1115                        point.get("y").and_then(serde_json::Value::as_f64),
1116                    ) else {
1117                        return tool_error_with_hint(
1118                            format!("ref not found: {ref_id}"),
1119                            RecoveryHint::CheckInput,
1120                        );
1121                    };
1122                    return match self
1123                        .bridge
1124                        .native_click(params.webview_label.as_deref(), x, y)
1125                    {
1126                        Ok(()) => json_result(
1127                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1128                        ),
1129                        Err(e) => tool_error(e),
1130                    };
1131                }
1132                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1133                self.eval_bridge(&code, params.webview_label.as_deref())
1134                    .await
1135            }
1136            InteractAction::DoubleClick => {
1137                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1138                    return tool_disabled("interact.double_click");
1139                }
1140                let Some(ref_id) = &params.ref_id else {
1141                    return missing_param("ref_id", "double_click");
1142                };
1143                let code = format!(
1144                    "return window.__VICTAURI__?.doubleClick({})",
1145                    js_string(ref_id)
1146                );
1147                self.eval_bridge(&code, params.webview_label.as_deref())
1148                    .await
1149            }
1150            InteractAction::Hover => {
1151                if !self.state.privacy.is_tool_enabled("interact.hover") {
1152                    return tool_disabled("interact.hover");
1153                }
1154                let Some(ref_id) = &params.ref_id else {
1155                    return missing_param("ref_id", "hover");
1156                };
1157                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1158                self.eval_bridge(&code, params.webview_label.as_deref())
1159                    .await
1160            }
1161            InteractAction::Focus => {
1162                if !self.state.privacy.is_tool_enabled("interact.focus") {
1163                    return tool_disabled("interact.focus");
1164                }
1165                let Some(ref_id) = &params.ref_id else {
1166                    return missing_param("ref_id", "focus");
1167                };
1168                let code = format!(
1169                    "return window.__VICTAURI__?.focusElement({})",
1170                    js_string(ref_id)
1171                );
1172                self.eval_bridge(&code, params.webview_label.as_deref())
1173                    .await
1174            }
1175            InteractAction::ScrollIntoView => {
1176                if !self
1177                    .state
1178                    .privacy
1179                    .is_tool_enabled("interact.scroll_into_view")
1180                {
1181                    return tool_disabled("interact.scroll_into_view");
1182                }
1183                let ref_arg = params
1184                    .ref_id
1185                    .as_ref()
1186                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1187                let x = params.x.unwrap_or(0.0);
1188                let y = params.y.unwrap_or(0.0);
1189                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1190                self.eval_bridge(&code, params.webview_label.as_deref())
1191                    .await
1192            }
1193            InteractAction::SelectOption => {
1194                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1195                    return tool_disabled("interact.select_option");
1196                }
1197                let Some(ref_id) = &params.ref_id else {
1198                    return missing_param("ref_id", "select_option");
1199                };
1200                let values_vec;
1201                let values: &[String] = match (&params.values, &params.value) {
1202                    (Some(v), _) => v,
1203                    (None, Some(v)) => {
1204                        values_vec = vec![v.clone()];
1205                        &values_vec
1206                    }
1207                    (None, None) => &[],
1208                };
1209                let values_json =
1210                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1211                let code = format!(
1212                    "return window.__VICTAURI__?.selectOption({}, {})",
1213                    js_string(ref_id),
1214                    values_json
1215                );
1216                self.eval_bridge(&code, params.webview_label.as_deref())
1217                    .await
1218            }
1219        }
1220    }
1221
1222    #[tool(
1223        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.",
1224        annotations(
1225            read_only_hint = false,
1226            destructive_hint = false,
1227            idempotent_hint = false,
1228            open_world_hint = false
1229        )
1230    )]
1231    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1232        match params.action {
1233            InputAction::Fill => {
1234                if !self.state.privacy.is_tool_enabled("fill") {
1235                    return tool_disabled("fill");
1236                }
1237                let Some(ref_id) = &params.ref_id else {
1238                    return missing_param("ref_id", "fill");
1239                };
1240                let Some(value) = &params.value else {
1241                    return missing_param("value", "fill");
1242                };
1243                let code = format!(
1244                    "return window.__VICTAURI__?.fill({}, {})",
1245                    js_string(ref_id),
1246                    js_string(value)
1247                );
1248                self.eval_bridge(&code, params.webview_label.as_deref())
1249                    .await
1250            }
1251            InputAction::TypeText => {
1252                if !self.state.privacy.is_tool_enabled("type_text") {
1253                    return tool_disabled("type_text");
1254                }
1255                let Some(ref_id) = &params.ref_id else {
1256                    return missing_param("ref_id", "type_text");
1257                };
1258                let Some(text) = &params.text else {
1259                    return missing_param("text", "type_text");
1260                };
1261                if params.trusted.unwrap_or(false) {
1262                    // Focus the element via JS, then deliver real OS keystrokes
1263                    // (isTrusted: true) for handlers that reject synthetic events.
1264                    let focus = format!(
1265                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1266                        js_string(ref_id)
1267                    );
1268                    let focused = self
1269                        .eval_with_return(&focus, params.webview_label.as_deref())
1270                        .await
1271                        .unwrap_or_default();
1272                    if focused != "true" {
1273                        return tool_error_with_hint(
1274                            format!("ref not found or not focusable: {ref_id}"),
1275                            RecoveryHint::CheckInput,
1276                        );
1277                    }
1278                    return match self
1279                        .bridge
1280                        .native_type_text(params.webview_label.as_deref(), text)
1281                    {
1282                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1283                        Err(e) => tool_error(e),
1284                    };
1285                }
1286                let code = format!(
1287                    "return window.__VICTAURI__?.type({}, {})",
1288                    js_string(ref_id),
1289                    js_string(text)
1290                );
1291                self.eval_bridge(&code, params.webview_label.as_deref())
1292                    .await
1293            }
1294            InputAction::PressKey => {
1295                if !self.state.privacy.is_tool_enabled("input.press_key") {
1296                    return tool_disabled("input.press_key");
1297                }
1298                let Some(key) = &params.key else {
1299                    return missing_param("key", "press_key");
1300                };
1301                if params.trusted.unwrap_or(false) {
1302                    // Optionally focus a target element, then send a real OS key.
1303                    if let Some(ref_id) = &params.ref_id {
1304                        let focus = format!(
1305                            "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1306                            js_string(ref_id)
1307                        );
1308                        let _ = self
1309                            .eval_with_return(&focus, params.webview_label.as_deref())
1310                            .await;
1311                    }
1312                    return match self.bridge.native_key(params.webview_label.as_deref(), key) {
1313                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1314                        Err(e) => tool_error(e),
1315                    };
1316                }
1317                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1318                self.eval_bridge(&code, params.webview_label.as_deref())
1319                    .await
1320            }
1321        }
1322    }
1323
1324    #[tool(
1325        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.",
1326        annotations(
1327            read_only_hint = false,
1328            destructive_hint = false,
1329            idempotent_hint = true,
1330            open_world_hint = false
1331        )
1332    )]
1333    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1334        self.track_tool_call();
1335        match params.action {
1336            WindowAction::GetState => {
1337                let states = self.bridge.get_window_states(params.label.as_deref());
1338                // A specific label that matches no window is an error, not an
1339                // empty array (which reads as "success, no state").
1340                if states.is_empty()
1341                    && let Some(label) = params.label.as_deref()
1342                {
1343                    return tool_error(format!(
1344                        "window not found: '{label}' (use window.list to see available labels)"
1345                    ));
1346                }
1347                json_result(&states)
1348            }
1349            WindowAction::List => {
1350                let labels = self.bridge.list_window_labels();
1351                json_result(&labels)
1352            }
1353            WindowAction::Manage => {
1354                if !self.state.privacy.is_tool_enabled("window.manage") {
1355                    return tool_disabled("window.manage");
1356                }
1357                let Some(manage_action) = &params.manage_action else {
1358                    return missing_param("manage_action", "manage");
1359                };
1360                match self
1361                    .bridge
1362                    .manage_window(params.label.as_deref(), manage_action.as_str())
1363                {
1364                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1365                    Err(e) => tool_error(e),
1366                }
1367            }
1368            WindowAction::Resize => {
1369                if !self.state.privacy.is_tool_enabled("window.resize") {
1370                    return tool_disabled("window.resize");
1371                }
1372                let Some(width) = params.width else {
1373                    return missing_param("width", "resize");
1374                };
1375                let Some(height) = params.height else {
1376                    return missing_param("height", "resize");
1377                };
1378                if width == 0 || height == 0 {
1379                    return tool_error_with_hint(
1380                        format!(
1381                            "invalid window size {width}x{height}: width and height must be > 0"
1382                        ),
1383                        RecoveryHint::CheckInput,
1384                    );
1385                }
1386                match self
1387                    .bridge
1388                    .resize_window(params.label.as_deref(), width, height)
1389                {
1390                    Ok(()) => {
1391                        let result =
1392                            serde_json::json!({"ok": true, "width": width, "height": height});
1393                        CallToolResult::success(vec![Content::text(result.to_string())])
1394                    }
1395                    Err(e) => tool_error(e),
1396                }
1397            }
1398            WindowAction::MoveTo => {
1399                if !self.state.privacy.is_tool_enabled("window.move_to") {
1400                    return tool_disabled("window.move_to");
1401                }
1402                let Some(x) = params.x else {
1403                    return missing_param("x", "move_to");
1404                };
1405                let Some(y) = params.y else {
1406                    return missing_param("y", "move_to");
1407                };
1408                match self.bridge.move_window(params.label.as_deref(), x, y) {
1409                    Ok(()) => {
1410                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1411                        CallToolResult::success(vec![Content::text(result.to_string())])
1412                    }
1413                    Err(e) => tool_error(e),
1414                }
1415            }
1416            WindowAction::SetTitle => {
1417                if !self.state.privacy.is_tool_enabled("window.set_title") {
1418                    return tool_disabled("window.set_title");
1419                }
1420                let Some(title) = &params.title else {
1421                    return missing_param("title", "set_title");
1422                };
1423                match self.bridge.set_window_title(params.label.as_deref(), title) {
1424                    Ok(()) => {
1425                        let result = serde_json::json!({"ok": true, "title": title});
1426                        CallToolResult::success(vec![Content::text(result.to_string())])
1427                    }
1428                    Err(e) => tool_error(e),
1429                }
1430            }
1431        }
1432    }
1433
1434    #[tool(
1435        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1436        annotations(
1437            read_only_hint = false,
1438            destructive_hint = true,
1439            idempotent_hint = false,
1440            open_world_hint = false
1441        )
1442    )]
1443    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1444        match params.action {
1445            StorageAction::Get => {
1446                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1447                    StorageType::Session => "getSessionStorage",
1448                    StorageType::Local => "getLocalStorage",
1449                };
1450                let key_arg = params
1451                    .key
1452                    .as_ref()
1453                    .map(|k| js_string(k))
1454                    .unwrap_or_default();
1455                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1456                self.eval_bridge(&code, params.webview_label.as_deref())
1457                    .await
1458            }
1459            StorageAction::Set => {
1460                if !self.state.privacy.is_tool_enabled("set_storage") {
1461                    return tool_disabled("set_storage");
1462                }
1463                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1464                    StorageType::Session => "setSessionStorage",
1465                    StorageType::Local => "setLocalStorage",
1466                };
1467                let Some(key) = &params.key else {
1468                    return missing_param("key", "set");
1469                };
1470                let value = params
1471                    .value
1472                    .as_ref()
1473                    .cloned()
1474                    .unwrap_or(serde_json::Value::Null);
1475                let value_json =
1476                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1477                let code = format!(
1478                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1479                    js_string(key)
1480                );
1481                self.eval_bridge(&code, params.webview_label.as_deref())
1482                    .await
1483            }
1484            StorageAction::Delete => {
1485                if !self.state.privacy.is_tool_enabled("delete_storage") {
1486                    return tool_disabled("delete_storage");
1487                }
1488                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1489                    StorageType::Session => "deleteSessionStorage",
1490                    StorageType::Local => "deleteLocalStorage",
1491                };
1492                let Some(key) = &params.key else {
1493                    return missing_param("key", "delete");
1494                };
1495                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1496                self.eval_bridge(&code, params.webview_label.as_deref())
1497                    .await
1498            }
1499            StorageAction::GetCookies => {
1500                self.eval_bridge(
1501                    "return window.__VICTAURI__?.getCookies()",
1502                    params.webview_label.as_deref(),
1503                )
1504                .await
1505            }
1506        }
1507    }
1508
1509    #[tool(
1510        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.",
1511        annotations(
1512            read_only_hint = false,
1513            destructive_hint = false,
1514            idempotent_hint = false,
1515            open_world_hint = false
1516        )
1517    )]
1518    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1519        match params.action {
1520            NavigateAction::GoTo => {
1521                if !self.state.privacy.is_tool_enabled("navigate") {
1522                    return tool_disabled("navigate");
1523                }
1524                let Some(url) = &params.url else {
1525                    return missing_param("url", "go_to");
1526                };
1527                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1528                    return tool_error(e);
1529                }
1530                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1531                self.eval_bridge(&code, params.webview_label.as_deref())
1532                    .await
1533            }
1534            NavigateAction::GoBack => {
1535                self.eval_bridge(
1536                    "return window.__VICTAURI__?.navigateBack()",
1537                    params.webview_label.as_deref(),
1538                )
1539                .await
1540            }
1541            NavigateAction::GetHistory => {
1542                self.eval_bridge(
1543                    "return window.__VICTAURI__?.getNavigationLog()",
1544                    params.webview_label.as_deref(),
1545                )
1546                .await
1547            }
1548            NavigateAction::SetDialogResponse => {
1549                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1550                    return tool_disabled("set_dialog_response");
1551                }
1552                let Some(dialog_type) = params.dialog_type else {
1553                    return missing_param("dialog_type", "set_dialog_response");
1554                };
1555                let Some(dialog_action) = params.dialog_action else {
1556                    return missing_param("dialog_action", "set_dialog_response");
1557                };
1558                let text_arg = params
1559                    .text
1560                    .as_ref()
1561                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1562                let code = format!(
1563                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1564                    js_string(dialog_type.as_str()),
1565                    js_string(dialog_action.as_str())
1566                );
1567                self.eval_bridge(&code, params.webview_label.as_deref())
1568                    .await
1569            }
1570            NavigateAction::GetDialogLog => {
1571                self.eval_bridge(
1572                    "return window.__VICTAURI__?.getDialogLog()",
1573                    params.webview_label.as_deref(),
1574                )
1575                .await
1576            }
1577        }
1578    }
1579
1580    #[tool(
1581        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).",
1582        annotations(
1583            read_only_hint = false,
1584            destructive_hint = false,
1585            idempotent_hint = false,
1586            open_world_hint = false
1587        )
1588    )]
1589    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1590        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1591        self.track_tool_call();
1592        if !self.state.privacy.is_tool_enabled("recording") {
1593            return tool_disabled("recording");
1594        }
1595        match params.action {
1596            RecordingAction::Start => {
1597                let session_id = params
1598                    .session_id
1599                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1600                match self.state.recorder.start(session_id.clone()) {
1601                    Ok(()) => {
1602                        let result = serde_json::json!({
1603                            "started": true,
1604                            "session_id": session_id,
1605                        });
1606                        CallToolResult::success(vec![Content::text(result.to_string())])
1607                    }
1608                    Err(e) => tool_error(e.to_string()),
1609                }
1610            }
1611            RecordingAction::Stop => match self.state.recorder.stop() {
1612                Some(session) => json_result(&session),
1613                None => tool_error("no recording is active"),
1614            },
1615            RecordingAction::Checkpoint => {
1616                let Some(id) = params.checkpoint_id else {
1617                    return missing_param("checkpoint_id", "checkpoint");
1618                };
1619                let state = params.state.unwrap_or(serde_json::Value::Null);
1620                match self
1621                    .state
1622                    .recorder
1623                    .checkpoint(id.clone(), params.checkpoint_label, state)
1624                {
1625                    Ok(()) => {
1626                        let result = serde_json::json!({
1627                            "created": true,
1628                            "checkpoint_id": id,
1629                            "event_index": self.state.recorder.event_count(),
1630                        });
1631                        CallToolResult::success(vec![Content::text(result.to_string())])
1632                    }
1633                    Err(e) => tool_error(e.to_string()),
1634                }
1635            }
1636            RecordingAction::ListCheckpoints => {
1637                let checkpoints = self.state.recorder.get_checkpoints();
1638                json_result(&checkpoints)
1639            }
1640            RecordingAction::GetEvents => {
1641                let events = self
1642                    .state
1643                    .recorder
1644                    .events_since(params.since_index.unwrap_or(0));
1645                json_result(&events)
1646            }
1647            RecordingAction::EventsBetween => {
1648                let Some(from) = &params.from else {
1649                    return missing_param("from", "events_between");
1650                };
1651                let Some(to) = &params.to else {
1652                    return missing_param("to", "events_between");
1653                };
1654                match self.state.recorder.events_between_checkpoints(from, to) {
1655                    Ok(events) => json_result(&events),
1656                    Err(e) => tool_error(e.to_string()),
1657                }
1658            }
1659            RecordingAction::GetReplay => {
1660                let calls = self.state.recorder.ipc_replay_sequence();
1661                json_result(&calls)
1662            }
1663            RecordingAction::Export => match self.state.recorder.export() {
1664                Some(s) => {
1665                    let json = serde_json::to_string_pretty(&s)
1666                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1667                    CallToolResult::success(vec![Content::text(json)])
1668                }
1669                None => tool_error("no recording is active — start one first"),
1670            },
1671            RecordingAction::Import => {
1672                let Some(session_json) = &params.session_json else {
1673                    return missing_param("session_json", "import");
1674                };
1675                if session_json.len() > MAX_SESSION_JSON {
1676                    return tool_error("session JSON exceeds maximum size (10 MB)");
1677                }
1678                let session: victauri_core::RecordedSession =
1679                    match serde_json::from_str(session_json) {
1680                        Ok(s) => s,
1681                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1682                    };
1683
1684                let result = serde_json::json!({
1685                    "imported": true,
1686                    "session_id": session.id,
1687                    "event_count": session.events.len(),
1688                    "checkpoint_count": session.checkpoints.len(),
1689                    "started_at": session.started_at.to_rfc3339(),
1690                });
1691                self.state.recorder.import(session);
1692                CallToolResult::success(vec![Content::text(result.to_string())])
1693            }
1694            RecordingAction::Flush => {
1695                if !self.state.recorder.is_recording() {
1696                    return tool_error("no active recording — start a recording first");
1697                }
1698                let code = "return window.__VICTAURI__?.getEventStream(0)";
1699                match self
1700                    .eval_with_return(code, params.webview_label.as_deref())
1701                    .await
1702                {
1703                    Ok(result_str) => {
1704                        let events: Vec<serde_json::Value> =
1705                            serde_json::from_str(&result_str).unwrap_or_default();
1706                        let mut count = 0u64;
1707                        for ev in &events {
1708                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1709                                self.state.event_log.push(app_event.clone());
1710                                self.state.recorder.record_event(app_event);
1711                                count += 1;
1712                            }
1713                        }
1714                        json_result(&serde_json::json!({
1715                            "flushed": true,
1716                            "events_captured": count,
1717                        }))
1718                    }
1719                    Err(e) => tool_error(format!("flush failed: {e}")),
1720                }
1721            }
1722            RecordingAction::Replay => {
1723                let calls = self.state.recorder.ipc_replay_sequence();
1724                if calls.is_empty() {
1725                    return tool_error("no IPC calls recorded — record a session first");
1726                }
1727                let mut replay_results = Vec::new();
1728                for call in &calls {
1729                    let code = format!(
1730                        "return window.__TAURI_INTERNALS__.invoke({})",
1731                        js_string(&call.command)
1732                    );
1733                    let outcome = match self
1734                        .eval_with_return(&code, params.webview_label.as_deref())
1735                        .await
1736                    {
1737                        Ok(result_str) => {
1738                            let value: serde_json::Value = serde_json::from_str(&result_str)
1739                                .unwrap_or(serde_json::Value::String(result_str));
1740                            let shape = crate::introspection::JsonShape::from_value(&value);
1741                            serde_json::json!({
1742                                "command": call.command,
1743                                "status": "ok",
1744                                "response_type": shape.type_name(),
1745                            })
1746                        }
1747                        Err(e) => {
1748                            serde_json::json!({
1749                                "command": call.command,
1750                                "status": "error",
1751                                "error": e,
1752                            })
1753                        }
1754                    };
1755                    replay_results.push(outcome);
1756                }
1757                let passed = replay_results
1758                    .iter()
1759                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1760                    .count();
1761                let result = serde_json::json!({
1762                    "replayed": replay_results.len(),
1763                    "passed": passed,
1764                    "failed": replay_results.len() - passed,
1765                    "results": replay_results,
1766                });
1767                json_result(&result)
1768            }
1769        }
1770    }
1771
1772    #[tool(
1773        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).",
1774        annotations(
1775            read_only_hint = true,
1776            destructive_hint = false,
1777            idempotent_hint = true,
1778            open_world_hint = false
1779        )
1780    )]
1781    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1782        match params.action {
1783            InspectAction::GetStyles => {
1784                let Some(ref_id) = &params.ref_id else {
1785                    return missing_param("ref_id", "get_styles");
1786                };
1787                let props_arg = match &params.properties {
1788                    Some(props) => {
1789                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1790                        format!("[{}]", arr.join(","))
1791                    }
1792                    None => "null".to_string(),
1793                };
1794                let code = format!(
1795                    "return window.__VICTAURI__?.getStyles({}, {})",
1796                    js_string(ref_id),
1797                    props_arg
1798                );
1799                self.eval_bridge(&code, params.webview_label.as_deref())
1800                    .await
1801            }
1802            InspectAction::GetBoundingBoxes => {
1803                let Some(ref_ids) = &params.ref_ids else {
1804                    return missing_param("ref_ids", "get_bounding_boxes");
1805                };
1806                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1807                let code = format!(
1808                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1809                    refs.join(",")
1810                );
1811                self.eval_bridge(&code, params.webview_label.as_deref())
1812                    .await
1813            }
1814            InspectAction::Highlight => {
1815                let Some(ref_id) = &params.ref_id else {
1816                    return missing_param("ref_id", "highlight");
1817                };
1818                let color_arg = match &params.color {
1819                    Some(c) => match sanitize_css_color(c) {
1820                        Ok(safe) => format!("\"{safe}\""),
1821                        Err(e) => return tool_error(e),
1822                    },
1823                    None => "null".to_string(),
1824                };
1825                let label_arg = match &params.label {
1826                    Some(l) => js_string(l),
1827                    None => "null".to_string(),
1828                };
1829                let code = format!(
1830                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1831                    js_string(ref_id),
1832                    color_arg,
1833                    label_arg
1834                );
1835                self.eval_bridge(&code, params.webview_label.as_deref())
1836                    .await
1837            }
1838            InspectAction::ClearHighlights => {
1839                self.eval_bridge(
1840                    "return window.__VICTAURI__?.clearHighlights()",
1841                    params.webview_label.as_deref(),
1842                )
1843                .await
1844            }
1845            InspectAction::AuditAccessibility => {
1846                self.eval_bridge(
1847                    "return window.__VICTAURI__?.auditAccessibility()",
1848                    params.webview_label.as_deref(),
1849                )
1850                .await
1851            }
1852            InspectAction::GetPerformance => {
1853                self.eval_bridge(
1854                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1855                    params.webview_label.as_deref(),
1856                )
1857                .await
1858            }
1859        }
1860    }
1861
1862    #[tool(
1863        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1864        annotations(
1865            read_only_hint = false,
1866            destructive_hint = false,
1867            idempotent_hint = true,
1868            open_world_hint = false
1869        )
1870    )]
1871    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1872        match params.action {
1873            CssAction::Inject => {
1874                if !self.state.privacy.is_tool_enabled("inject_css") {
1875                    return tool_disabled("inject_css");
1876                }
1877                let Some(css) = &params.css else {
1878                    return missing_param("css", "inject");
1879                };
1880                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1881                self.eval_bridge(&code, params.webview_label.as_deref())
1882                    .await
1883            }
1884            CssAction::Remove => {
1885                if !self.state.privacy.is_tool_enabled("css.remove") {
1886                    return tool_disabled("css.remove");
1887                }
1888                self.eval_bridge(
1889                    "return window.__VICTAURI__?.removeInjectedCss()",
1890                    params.webview_label.as_deref(),
1891                )
1892                .await
1893            }
1894        }
1895    }
1896
1897    #[tool(
1898        description = "Network request interception (Playwright route() equivalent, no CDP). \
1899            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
1900            Actions:\n\
1901            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
1902              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
1903              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
1904              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
1905            - `list`: list active rules.\n\
1906            - `clear` (by `id`) / `clear_all`: remove rules.\n\
1907            - `matches`: log of intercepted requests.\n\
1908            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
1909            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
1910            For Tauri IPC-layer faults, prefer the `fault` tool.",
1911        annotations(
1912            read_only_hint = false,
1913            destructive_hint = false,
1914            idempotent_hint = false,
1915            open_world_hint = false
1916        )
1917    )]
1918    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
1919        self.track_tool_call();
1920        match params.action {
1921            RouteAction::Add => {
1922                if !self.state.privacy.is_tool_enabled("route.add") {
1923                    return tool_disabled("route.add");
1924                }
1925                let Some(pattern) = &params.pattern else {
1926                    return missing_param("pattern", "add");
1927                };
1928                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
1929                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
1930                let mut rule = serde_json::json!({
1931                    "pattern": pattern,
1932                    "match_type": match_type.as_str(),
1933                    "action": behavior.as_str(),
1934                });
1935                if let Some(m) = &params.method {
1936                    rule["method"] = serde_json::json!(m);
1937                }
1938                if let Some(s) = params.status {
1939                    rule["status"] = serde_json::json!(s);
1940                }
1941                if let Some(st) = &params.status_text {
1942                    rule["status_text"] = serde_json::json!(st);
1943                }
1944                if let Some(h) = &params.headers {
1945                    rule["headers"] = h.clone();
1946                }
1947                if let Some(b) = &params.body {
1948                    // A JSON string body is passed through as-is; structured JSON
1949                    // is stringified so the bridge sends valid JSON text.
1950                    rule["body"] = match b {
1951                        serde_json::Value::String(s) => serde_json::json!(s),
1952                        other => serde_json::json!(other.to_string()),
1953                    };
1954                }
1955                if let Some(ct) = &params.content_type {
1956                    rule["content_type"] = serde_json::json!(ct);
1957                }
1958                if let Some(d) = params.delay_ms {
1959                    rule["delay_ms"] = serde_json::json!(d);
1960                }
1961                if let Some(t) = params.times {
1962                    rule["times"] = serde_json::json!(t);
1963                }
1964                let code = format!(
1965                    "return window.__VICTAURI__?.addRoute({})",
1966                    js_string(&rule.to_string())
1967                );
1968                self.eval_bridge(&code, params.webview_label.as_deref())
1969                    .await
1970            }
1971            RouteAction::List => {
1972                self.eval_bridge(
1973                    "return window.__VICTAURI__?.getRouteRules()",
1974                    params.webview_label.as_deref(),
1975                )
1976                .await
1977            }
1978            RouteAction::Clear => {
1979                let Some(id) = params.id else {
1980                    return missing_param("id", "clear");
1981                };
1982                let code = format!("return window.__VICTAURI__?.clearRoute({id})");
1983                self.eval_bridge(&code, params.webview_label.as_deref())
1984                    .await
1985            }
1986            RouteAction::ClearAll => {
1987                self.eval_bridge(
1988                    "return window.__VICTAURI__?.clearRoutes()",
1989                    params.webview_label.as_deref(),
1990                )
1991                .await
1992            }
1993            RouteAction::Matches => {
1994                let limit = params.limit.unwrap_or(100);
1995                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
1996                self.eval_bridge(&code, params.webview_label.as_deref())
1997                    .await
1998            }
1999        }
2000    }
2001
2002    #[tool(
2003        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2004            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2005            `logs` (network/console). Actions:\n\
2006            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2007              `with_events=true` to also start the event recorder.\n\
2008            - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2009            - `status`: active flag + buffered frame count.\n\
2010            - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2011        annotations(
2012            read_only_hint = false,
2013            destructive_hint = false,
2014            idempotent_hint = false,
2015            open_world_hint = false
2016        )
2017    )]
2018    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2019        self.track_tool_call();
2020        if !self.state.privacy.is_tool_enabled("trace")
2021            || !self.state.privacy.is_tool_enabled("screenshot")
2022        {
2023            return tool_disabled("trace");
2024        }
2025        match params.action {
2026            TraceAction::Start => {
2027                let interval = params.interval_ms.unwrap_or(500);
2028                let max_frames = params.max_frames.unwrap_or(60);
2029                let label = params.webview_label.clone();
2030                let generation = self
2031                    .state
2032                    .screencast
2033                    .start(interval, max_frames, label.clone());
2034
2035                let mut events_started = false;
2036                if params.with_events.unwrap_or(false) {
2037                    let session_id = uuid::Uuid::new_v4().to_string();
2038                    if self.state.recorder.start(session_id).is_ok() {
2039                        events_started = true;
2040                    }
2041                }
2042
2043                // Background capture task: snapshot the window each interval until
2044                // the screencast is stopped (or superseded by a newer start).
2045                let bridge = self.bridge.clone();
2046                let screencast = self.state.screencast.clone();
2047                tokio::spawn(async move {
2048                    let t0 = std::time::Instant::now();
2049                    while screencast.is_active() && screencast.generation() == generation {
2050                        if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2051                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2052                        {
2053                            use base64::Engine;
2054                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2055                            #[allow(clippy::cast_possible_truncation)]
2056                            screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2057                        }
2058                        tokio::time::sleep(std::time::Duration::from_millis(
2059                            screencast.interval_ms(),
2060                        ))
2061                        .await;
2062                    }
2063                });
2064
2065                json_result(&serde_json::json!({
2066                    "started": true,
2067                    "interval_ms": interval.max(50),
2068                    "max_frames": max_frames.clamp(1, 600),
2069                    "with_events": events_started,
2070                }))
2071            }
2072            TraceAction::Stop => {
2073                let frame_count = self.state.screencast.stop();
2074                let timestamps = self.state.screencast.frame_timestamps();
2075                let duration_ms = timestamps.last().copied().unwrap_or(0);
2076                let event_count = self.state.recorder.event_count();
2077                json_result(&serde_json::json!({
2078                    "stopped": true,
2079                    "frame_count": frame_count,
2080                    "duration_ms": duration_ms,
2081                    "frame_timestamps_ms": timestamps,
2082                    "recorded_event_count": event_count,
2083                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2084                }))
2085            }
2086            TraceAction::Status => json_result(&serde_json::json!({
2087                "active": self.state.screencast.is_active(),
2088                "frame_count": self.state.screencast.frame_count(),
2089                "interval_ms": self.state.screencast.interval_ms(),
2090            })),
2091            TraceAction::Frames => {
2092                let limit = params.limit.unwrap_or(0);
2093                let frames = self.state.screencast.frames(limit);
2094                let items: Vec<Content> = frames
2095                    .into_iter()
2096                    .map(|f| Content::image(f.data_b64, "image/png"))
2097                    .collect();
2098                if items.is_empty() {
2099                    return json_result(&serde_json::json!({ "frames": 0 }));
2100                }
2101                CallToolResult::success(items)
2102            }
2103        }
2104    }
2105
2106    #[tool(
2107        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).",
2108        annotations(
2109            read_only_hint = true,
2110            destructive_hint = false,
2111            idempotent_hint = true,
2112            open_world_hint = false
2113        )
2114    )]
2115    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2116        match params.action {
2117            LogsAction::Console => {
2118                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2119                let base = if since_arg.is_empty() {
2120                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
2121                } else {
2122                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2123                };
2124                let code = if let Some(limit) = params.limit {
2125                    format!("return ({base} || []).slice(-{limit})")
2126                } else {
2127                    format!("return {base}")
2128                };
2129                self.eval_bridge(&code, params.webview_label.as_deref())
2130                    .await
2131            }
2132            LogsAction::Network => {
2133                let filter_arg = params
2134                    .filter
2135                    .as_ref()
2136                    .map_or_else(|| "null".to_string(), |f| js_string(f));
2137                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2138                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2139                let code = trimmed_log_js(&source, limit);
2140                self.eval_bridge(&code, params.webview_label.as_deref())
2141                    .await
2142            }
2143            LogsAction::Ipc => {
2144                let wait = params.wait_for_capture.unwrap_or(false);
2145                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2146                if wait {
2147                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2148                    let code = format!(
2149                        r"return (async function() {{
2150                            await window.__VICTAURI__.waitForIpcComplete(500);
2151                            return (function() {{ {inner} }})();
2152                        }})()"
2153                    );
2154                    let timeout = std::time::Duration::from_millis(5000);
2155                    match self
2156                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2157                        .await
2158                    {
2159                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2160                        Err(e) => tool_error(e),
2161                    }
2162                } else {
2163                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2164                    self.eval_bridge(&code, params.webview_label.as_deref())
2165                        .await
2166                }
2167            }
2168            LogsAction::Navigation => {
2169                let code = if let Some(limit) = params.limit {
2170                    format!(
2171                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2172                    )
2173                } else {
2174                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
2175                };
2176                self.eval_bridge(&code, params.webview_label.as_deref())
2177                    .await
2178            }
2179            LogsAction::Dialogs => {
2180                let code = if let Some(limit) = params.limit {
2181                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2182                } else {
2183                    "return window.__VICTAURI__?.getDialogLog()".to_string()
2184                };
2185                self.eval_bridge(&code, params.webview_label.as_deref())
2186                    .await
2187            }
2188            LogsAction::Events => {
2189                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2190                let base = if since_arg.is_empty() {
2191                    "window.__VICTAURI__?.getEventStream()".to_string()
2192                } else {
2193                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
2194                };
2195                let code = if let Some(limit) = params.limit {
2196                    format!("return ({base} || []).slice(-{limit})")
2197                } else {
2198                    format!("return {base}")
2199                };
2200                self.eval_bridge(&code, params.webview_label.as_deref())
2201                    .await
2202            }
2203            LogsAction::SlowIpc => {
2204                let Some(threshold) = params.threshold_ms else {
2205                    return missing_param("threshold_ms", "slow_ipc");
2206                };
2207                let limit = params.limit.unwrap_or(20);
2208                let mb = MAX_LOG_FIELD_BYTES;
2209                let code = format!(
2210                    r"return (function() {{
2211                        var MB = {mb};
2212                        function trimField(v) {{
2213                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2214                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2215                            return v;
2216                        }}
2217                        function trimEntry(e) {{ if (e == null || typeof e !== 'object') return e; var o = {{}}; for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) o[k] = trimField(e[k]); }} return o; }}
2218                        var log = window.__VICTAURI__?.getIpcLog() || [];
2219                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2220                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2221                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2222                    }})()",
2223                );
2224                self.eval_bridge(&code, None).await
2225            }
2226        }
2227    }
2228
2229    // ── Backend Introspection ────────────────────────────────────────────────
2230
2231    #[tool(
2232        description = "Deep backend introspection — command profiling, IPC contract testing, \
2233            coverage, startup timing, capability auditing, database diagnostics, process \
2234            enumeration, and event bus monitoring. \
2235            These features exploit Victauri's position inside the Rust process.\n\n\
2236            Actions:\n\
2237            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2238            - `coverage`: Which registered commands have been called during this session.\n\
2239            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2240            - `contract_check`: Check all recorded contracts for schema drift.\n\
2241            - `contract_list`: List all recorded contract baselines.\n\
2242            - `contract_clear`: Clear all recorded contract baselines.\n\
2243            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2244            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2245            - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2246            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2247            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2248            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2249            - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2250            - `event_bus_clear`: Clear the event bus capture buffer.",
2251        annotations(
2252            read_only_hint = true,
2253            destructive_hint = false,
2254            idempotent_hint = true,
2255            open_world_hint = false
2256        )
2257    )]
2258    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2259        self.track_tool_call();
2260        if !self.state.privacy.is_tool_enabled("introspect") {
2261            return tool_disabled("introspect");
2262        }
2263
2264        match params.action {
2265            IntrospectAction::CommandTimings => {
2266                let mut stats = self.state.command_timings.all_stats();
2267                if let Some(threshold) = params.slow_threshold_ms {
2268                    stats.retain(|s| s.avg_ms >= threshold);
2269                }
2270                let result = serde_json::json!({
2271                    "commands": stats,
2272                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
2273                    "slow_threshold_ms": params.slow_threshold_ms,
2274                });
2275                json_result(&result)
2276            }
2277            IntrospectAction::Coverage => {
2278                let registered: Vec<String> = self
2279                    .state
2280                    .registry
2281                    .list()
2282                    .iter()
2283                    .map(|c| c.name.clone())
2284                    .collect();
2285
2286                let code = "return window.__VICTAURI__?.getIpcLog()";
2287                let invoked: std::collections::HashSet<String> = match self
2288                    .eval_with_return(code, params.webview_label.as_deref())
2289                    .await
2290                {
2291                    Ok(json_str) => {
2292                        if let Ok(entries) =
2293                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2294                        {
2295                            entries
2296                                .iter()
2297                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2298                                .map(String::from)
2299                                .collect()
2300                        } else {
2301                            std::collections::HashSet::new()
2302                        }
2303                    }
2304                    Err(_) => std::collections::HashSet::new(),
2305                };
2306
2307                let uncovered: Vec<&String> = registered
2308                    .iter()
2309                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2310                    .collect();
2311
2312                let coverage_pct = if registered.is_empty() {
2313                    100.0
2314                } else {
2315                    let covered = registered.len() - uncovered.len();
2316                    (covered as f64 / registered.len() as f64) * 100.0
2317                };
2318
2319                let result = serde_json::json!({
2320                    "registered_commands": registered.len(),
2321                    "invoked_commands": invoked.len(),
2322                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2323                    "uncovered": uncovered,
2324                    "invoked_not_registered": invoked.iter()
2325                        .filter(|cmd| !registered.contains(cmd))
2326                        .collect::<Vec<_>>(),
2327                });
2328                json_result(&result)
2329            }
2330            IntrospectAction::ContractRecord => {
2331                let Some(command) = params.command else {
2332                    return missing_param("command", "contract_record");
2333                };
2334                let args_json = params.args.unwrap_or(serde_json::json!({}));
2335                let args_str =
2336                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2337                let code = format!(
2338                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2339                    js_string(&command)
2340                );
2341                match self
2342                    .eval_with_return(&code, params.webview_label.as_deref())
2343                    .await
2344                {
2345                    Ok(result_str) => {
2346                        let value: serde_json::Value = serde_json::from_str(&result_str)
2347                            .unwrap_or(serde_json::Value::String(result_str.clone()));
2348                        let shape = crate::introspection::JsonShape::from_value(&value);
2349                        let sample = if result_str.len() > 4096 {
2350                            format!("{}...(truncated)", &result_str[..4096])
2351                        } else {
2352                            result_str
2353                        };
2354                        let baseline = crate::introspection::ContractBaseline {
2355                            command: command.clone(),
2356                            args: args_json,
2357                            shape: shape.clone(),
2358                            sample,
2359                            recorded_at: chrono_now(),
2360                        };
2361                        self.state.contract_store.record(baseline);
2362                        let result = serde_json::json!({
2363                            "recorded": true,
2364                            "command": command,
2365                            "shape_type": shape.type_name(),
2366                        });
2367                        json_result(&result)
2368                    }
2369                    Err(e) => tool_error(format!(
2370                        "failed to invoke '{command}' for contract recording: {e}"
2371                    )),
2372                }
2373            }
2374            IntrospectAction::ContractCheck => {
2375                let baselines = self.state.contract_store.all();
2376                if baselines.is_empty() {
2377                    return json_result(&serde_json::json!({
2378                        "checked": 0,
2379                        "message": "no contract baselines recorded — use contract_record first",
2380                    }));
2381                }
2382                let mut results = Vec::new();
2383                for baseline in &baselines {
2384                    let args_str =
2385                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2386                    let code = format!(
2387                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2388                        js_string(&baseline.command)
2389                    );
2390                    match self
2391                        .eval_with_return(&code, params.webview_label.as_deref())
2392                        .await
2393                    {
2394                        Ok(result_str) => {
2395                            let value: serde_json::Value = serde_json::from_str(&result_str)
2396                                .unwrap_or(serde_json::Value::String(result_str));
2397                            let current_shape = crate::introspection::JsonShape::from_value(&value);
2398                            let drift = crate::introspection::diff_shapes(
2399                                &baseline.shape,
2400                                &current_shape,
2401                                &baseline.command,
2402                            );
2403                            results.push(drift);
2404                        }
2405                        Err(e) => {
2406                            results.push(crate::introspection::ContractDrift {
2407                                command: baseline.command.clone(),
2408                                new_fields: Vec::new(),
2409                                removed_fields: Vec::new(),
2410                                type_changes: Vec::new(),
2411                                shape_matches: false,
2412                            });
2413                            tracing::warn!(
2414                                command = %baseline.command,
2415                                error = %e,
2416                                "contract check invocation failed"
2417                            );
2418                        }
2419                    }
2420                }
2421                let passing = results.iter().filter(|r| r.shape_matches).count();
2422                let result = serde_json::json!({
2423                    "checked": results.len(),
2424                    "passing": passing,
2425                    "failing": results.len() - passing,
2426                    "contracts": results,
2427                });
2428                json_result(&result)
2429            }
2430            IntrospectAction::ContractList => {
2431                let baselines = self.state.contract_store.all();
2432                let result = serde_json::json!({
2433                    "count": baselines.len(),
2434                    "baselines": baselines.iter().map(|b| serde_json::json!({
2435                        "command": b.command,
2436                        "shape_type": b.shape.type_name(),
2437                        "recorded_at": b.recorded_at,
2438                    })).collect::<Vec<_>>(),
2439                });
2440                json_result(&result)
2441            }
2442            IntrospectAction::ContractClear => {
2443                let cleared = self.state.contract_store.clear();
2444                json_result(&serde_json::json!({
2445                    "cleared": cleared,
2446                }))
2447            }
2448            IntrospectAction::StartupTiming => {
2449                let phases = self.state.startup_timeline.report();
2450                let result = serde_json::json!({
2451                    "phases": phases,
2452                    "total_ms": self.state.startup_timeline.total_ms(),
2453                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2454                });
2455                json_result(&result)
2456            }
2457            IntrospectAction::Capabilities => {
2458                let config = self.bridge.tauri_config();
2459                let live_windows = self.bridge.list_window_labels();
2460
2461                let result = serde_json::json!({
2462                    "app": {
2463                        "identifier": config.get("identifier"),
2464                        "product_name": config.get("product_name"),
2465                        "version": config.get("version"),
2466                    },
2467                    "security": config.get("security"),
2468                    "configured_windows": config.get("windows"),
2469                    "live_windows": live_windows,
2470                    "configured_plugins": config.get("plugins"),
2471                    "victauri": {
2472                        "registered_commands": self.state.registry.list().len(),
2473                        "auth_enabled": self.state.privacy.redaction_enabled,
2474                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
2475                        "disabled_tools": &self.state.privacy.disabled_tools,
2476                    },
2477                });
2478                json_result(&result)
2479            }
2480            #[allow(unused_variables)]
2481            IntrospectAction::DbHealth => {
2482                #[cfg(feature = "sqlite")]
2483                {
2484                    let db_path = params.db_path.clone();
2485                    match self.run_db_health(db_path.as_deref()).await {
2486                        Ok(health) => json_result(&health),
2487                        Err(e) => tool_error(format!("db_health failed: {e}")),
2488                    }
2489                }
2490                #[cfg(not(feature = "sqlite"))]
2491                {
2492                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2493                }
2494            }
2495            IntrospectAction::PluginState => {
2496                let recording_active = self.state.recorder.is_recording();
2497                let recording_events = self.state.recorder.event_count();
2498                let result = serde_json::json!({
2499                    "event_log": {
2500                        "size": self.state.event_log.len(),
2501                        "capacity": self.state.event_log.capacity(),
2502                    },
2503                    "registry": {
2504                        "commands_registered": self.state.registry.list().len(),
2505                    },
2506                    "recording": {
2507                        "active": recording_active,
2508                        "events_captured": recording_events,
2509                    },
2510                    "faults": {
2511                        "active_rules": self.state.fault_registry.list().len(),
2512                    },
2513                    "contracts": {
2514                        "baselines_recorded": self.state.contract_store.all().len(),
2515                    },
2516                    "timings": {
2517                        "commands_profiled": self.state.command_timings.all_stats().len(),
2518                    },
2519                    "event_bus": {
2520                        "captured_events": self.state.event_bus.len(),
2521                    },
2522                    "tasks": {
2523                        "total": self.state.task_tracker.list().len(),
2524                        "active": self.state.task_tracker.active_count(),
2525                    },
2526                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2527                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2528                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2529                });
2530                json_result(&result)
2531            }
2532            IntrospectAction::Processes => {
2533                let pid = std::process::id();
2534                let uptime = self.state.started_at.elapsed();
2535                let children = crate::introspection::enumerate_child_processes();
2536                let host_memory = crate::memory::current_stats();
2537
2538                let result = serde_json::json!({
2539                    "host": {
2540                        "pid": pid,
2541                        "uptime_secs": uptime.as_secs(),
2542                        "platform": std::env::consts::OS,
2543                        "arch": std::env::consts::ARCH,
2544                        "memory": host_memory,
2545                    },
2546                    "children": children.iter().map(|c| serde_json::json!({
2547                        "pid": c.pid,
2548                        "name": c.name,
2549                        "memory_bytes": c.memory_bytes,
2550                    })).collect::<Vec<_>>(),
2551                    "child_count": children.len(),
2552                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2553                });
2554                json_result(&result)
2555            }
2556            IntrospectAction::PluginTasks => {
2557                let tasks = self.state.task_tracker.list();
2558                let active = self.state.task_tracker.active_count();
2559                let result = serde_json::json!({
2560                    "total": tasks.len(),
2561                    "active": active,
2562                    "finished": tasks.len() - active,
2563                    "tasks": tasks,
2564                });
2565                json_result(&result)
2566            }
2567            IntrospectAction::EventBus => {
2568                let tauri_events = self.state.event_bus.events();
2569                let app_events = self.state.event_log.snapshot();
2570                let result = serde_json::json!({
2571                    "tauri_events": {
2572                        "count": tauri_events.len(),
2573                        "events": tauri_events,
2574                    },
2575                    "app_events": {
2576                        "count": app_events.len(),
2577                        "capacity": self.state.event_log.capacity(),
2578                        "events": app_events,
2579                    },
2580                });
2581                json_result(&result)
2582            }
2583            IntrospectAction::EventBusClear => {
2584                let tauri_cleared = self.state.event_bus.clear();
2585                self.state.event_log.clear();
2586                json_result(&serde_json::json!({
2587                    "tauri_events_cleared": tauri_cleared,
2588                    "app_events_cleared": true,
2589                }))
2590            }
2591        }
2592    }
2593
2594    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
2595
2596    #[tool(
2597        description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2598            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2599            CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2600            Actions:\n\
2601            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2602            - `list`: List all active fault injection rules.\n\
2603            - `clear`: Remove a specific fault rule (requires `command`).\n\
2604            - `clear_all`: Remove all fault rules.",
2605        annotations(
2606            read_only_hint = false,
2607            destructive_hint = true,
2608            idempotent_hint = false,
2609            open_world_hint = false
2610        )
2611    )]
2612    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2613        self.track_tool_call();
2614        if !self.state.privacy.is_tool_enabled("fault") {
2615            return tool_disabled("fault");
2616        }
2617
2618        match params.action {
2619            FaultAction::Inject => {
2620                let Some(command) = params.command else {
2621                    return missing_param("command", "inject");
2622                };
2623                let Some(fault_kind) = params.fault_type else {
2624                    return missing_param("fault_type", "inject");
2625                };
2626                let fault_type = match fault_kind {
2627                    FaultKind::Delay => {
2628                        let delay_ms = params.delay_ms.unwrap_or(1000);
2629                        crate::introspection::FaultType::Delay { delay_ms }
2630                    }
2631                    FaultKind::Error => {
2632                        let message = params
2633                            .error_message
2634                            .unwrap_or_else(|| "injected fault".to_string());
2635                        crate::introspection::FaultType::Error { message }
2636                    }
2637                    FaultKind::Drop => crate::introspection::FaultType::Drop,
2638                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2639                };
2640                let config = crate::introspection::FaultConfig {
2641                    command: command.clone(),
2642                    fault_type: fault_type.clone(),
2643                    trigger_count: 0,
2644                    max_triggers: params.max_triggers.unwrap_or(0),
2645                    created_at: std::time::Instant::now(),
2646                };
2647                self.state.fault_registry.inject(config);
2648                let result = serde_json::json!({
2649                    "injected": true,
2650                    "command": command,
2651                    "fault_type": fault_type,
2652                    "max_triggers": params.max_triggers.unwrap_or(0),
2653                });
2654                json_result(&result)
2655            }
2656            FaultAction::List => {
2657                let faults = self.state.fault_registry.list();
2658                let result = serde_json::json!({
2659                    "count": faults.len(),
2660                    "faults": faults.iter().map(|f| serde_json::json!({
2661                        "command": f.command,
2662                        "fault_type": f.fault_type,
2663                        "trigger_count": f.trigger_count,
2664                        "max_triggers": f.max_triggers,
2665                    })).collect::<Vec<_>>(),
2666                });
2667                json_result(&result)
2668            }
2669            FaultAction::Clear => {
2670                let Some(command) = params.command else {
2671                    return missing_param("command", "clear");
2672                };
2673                let removed = self.state.fault_registry.clear(&command);
2674                json_result(&serde_json::json!({
2675                    "removed": removed,
2676                    "command": command,
2677                }))
2678            }
2679            FaultAction::ClearAll => {
2680                let removed = self.state.fault_registry.clear_all();
2681                json_result(&serde_json::json!({
2682                    "removed": removed,
2683                }))
2684            }
2685        }
2686    }
2687
2688    // ── Cross-Layer Explanation ────────────────────────────────────────────
2689
2690    #[tool(
2691        description = "Correlate recent activity across all layers into a coherent narrative. \
2692            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2693            + window events across the Rust backend and webview simultaneously.\n\n\
2694            Actions:\n\
2695            - `summary`: High-level activity summary for the last N seconds (default 30). \
2696              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2697            - `last_action`: Correlate the most recent burst of events into a causal timeline \
2698              (e.g. 'IPC call → DOM update → console.log').\n\
2699            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2700        annotations(
2701            read_only_hint = true,
2702            destructive_hint = false,
2703            idempotent_hint = true,
2704            open_world_hint = false
2705        )
2706    )]
2707    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2708        self.track_tool_call();
2709        if !self.state.privacy.is_tool_enabled("explain") {
2710            return tool_disabled("explain");
2711        }
2712
2713        match params.action {
2714            ExplainAction::Summary => {
2715                let secs = params.seconds.unwrap_or(30);
2716                let since = chrono::Utc::now()
2717                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2718                let events = self.state.event_log.since(since);
2719
2720                let mut ipc_count = 0u64;
2721                let mut dom_mutations = 0u64;
2722                let mut state_changes = 0u64;
2723                let mut console_count = 0u64;
2724                let mut window_events = 0u64;
2725                let mut interactions = 0u64;
2726                let mut top_commands: HashMap<String, u64> = HashMap::new();
2727                let mut errors: Vec<String> = Vec::new();
2728
2729                for event in &events {
2730                    match event {
2731                        victauri_core::AppEvent::Ipc(call) => {
2732                            ipc_count += 1;
2733                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2734                            if let victauri_core::IpcResult::Err(e) = &call.result {
2735                                errors.push(format!("IPC {}: {e}", call.command));
2736                            }
2737                        }
2738                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2739                            dom_mutations += u64::from(*mutation_count)
2740                        }
2741                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2742                        victauri_core::AppEvent::Console { level, message, .. } => {
2743                            console_count += 1;
2744                            if level == "error" {
2745                                errors.push(format!("console.error: {message}"));
2746                            }
2747                        }
2748                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2749                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2750                        _ => {}
2751                    }
2752                }
2753
2754                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2755                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2756                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2757
2758                let narrative = format!(
2759                    "{ipc_count} IPC call{} in the last {secs}s{}. \
2760                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2761                     {console_count} console message{}, {window_events} window event{}. {}.",
2762                    if ipc_count == 1 { "" } else { "s" },
2763                    if top.is_empty() {
2764                        String::new()
2765                    } else {
2766                        format!(
2767                            ", dominated by {}",
2768                            top.iter()
2769                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2770                                .collect::<Vec<_>>()
2771                                .join(", ")
2772                        )
2773                    },
2774                    if dom_mutations == 1 { "" } else { "s" },
2775                    if interactions == 1 { "" } else { "s" },
2776                    if console_count == 1 { "" } else { "s" },
2777                    if window_events == 1 { "" } else { "s" },
2778                    if errors.is_empty() {
2779                        "No errors".to_string()
2780                    } else {
2781                        format!(
2782                            "{} error{}",
2783                            errors.len(),
2784                            if errors.len() == 1 { "" } else { "s" }
2785                        )
2786                    },
2787                );
2788
2789                let result = serde_json::json!({
2790                    "time_window_secs": secs,
2791                    "total_events": events.len(),
2792                    "ipc_calls": ipc_count,
2793                    "dom_mutations": dom_mutations,
2794                    "state_changes": state_changes,
2795                    "console_messages": console_count,
2796                    "window_events": window_events,
2797                    "interactions": interactions,
2798                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2799                        serde_json::json!({"command": cmd, "count": n})
2800                    }).collect::<Vec<_>>(),
2801                    "errors": errors,
2802                    "narrative": narrative,
2803                });
2804                json_result(&result)
2805            }
2806            ExplainAction::LastAction => {
2807                let secs = params.seconds.unwrap_or(5);
2808                let since = chrono::Utc::now()
2809                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2810                let events = self.state.event_log.since(since);
2811
2812                let timeline: Vec<serde_json::Value> = events
2813                    .iter()
2814                    .filter(|e| !e.is_internal())
2815                    .map(|event| match event {
2816                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
2817                            "time": call.timestamp.to_rfc3339_opts(
2818                                chrono::SecondsFormat::Millis, true
2819                            ),
2820                            "type": "ipc",
2821                            "detail": format!(
2822                                "{} {} ({}ms)",
2823                                call.command,
2824                                call.result,
2825                                call.duration_ms.unwrap_or(0)
2826                            ),
2827                        }),
2828                        victauri_core::AppEvent::DomMutation {
2829                            timestamp,
2830                            mutation_count,
2831                            webview_label,
2832                        } => serde_json::json!({
2833                            "time": timestamp.to_rfc3339_opts(
2834                                chrono::SecondsFormat::Millis, true
2835                            ),
2836                            "type": "dom_mutation",
2837                            "detail": format!(
2838                                "{mutation_count} element{} updated in {webview_label}",
2839                                if *mutation_count == 1 { "" } else { "s" }
2840                            ),
2841                        }),
2842                        victauri_core::AppEvent::DomInteraction {
2843                            timestamp,
2844                            action,
2845                            selector,
2846                            ..
2847                        } => serde_json::json!({
2848                            "time": timestamp.to_rfc3339_opts(
2849                                chrono::SecondsFormat::Millis, true
2850                            ),
2851                            "type": "interaction",
2852                            "detail": format!("{action} on {selector}"),
2853                        }),
2854                        victauri_core::AppEvent::StateChange {
2855                            timestamp,
2856                            key,
2857                            caused_by,
2858                        } => serde_json::json!({
2859                            "time": timestamp.to_rfc3339_opts(
2860                                chrono::SecondsFormat::Millis, true
2861                            ),
2862                            "type": "state_change",
2863                            "detail": format!(
2864                                "{key} changed{}",
2865                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2866                            ),
2867                        }),
2868                        victauri_core::AppEvent::Console {
2869                            timestamp,
2870                            level,
2871                            message,
2872                        } => serde_json::json!({
2873                            "time": timestamp.to_rfc3339_opts(
2874                                chrono::SecondsFormat::Millis, true
2875                            ),
2876                            "type": "console",
2877                            "detail": format!("console.{level}: {message}"),
2878                        }),
2879                        victauri_core::AppEvent::WindowEvent {
2880                            timestamp,
2881                            label,
2882                            event,
2883                        } => serde_json::json!({
2884                            "time": timestamp.to_rfc3339_opts(
2885                                chrono::SecondsFormat::Millis, true
2886                            ),
2887                            "type": "window_event",
2888                            "detail": format!("{event} on window '{label}'"),
2889                        }),
2890                        _ => serde_json::json!({
2891                            "time": event.timestamp().to_rfc3339_opts(
2892                                chrono::SecondsFormat::Millis, true
2893                            ),
2894                            "type": "other",
2895                            "detail": "unknown event type",
2896                        }),
2897                    })
2898                    .collect();
2899
2900                let narrative = if timeline.is_empty() {
2901                    format!("No activity in the last {secs}s.")
2902                } else {
2903                    let parts: Vec<String> = timeline
2904                        .iter()
2905                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2906                        .map(String::from)
2907                        .collect();
2908                    parts.join(" → ")
2909                };
2910
2911                let result = serde_json::json!({
2912                    "time_window_secs": secs,
2913                    "event_count": timeline.len(),
2914                    "timeline": timeline,
2915                    "narrative": narrative,
2916                });
2917                json_result(&result)
2918            }
2919            ExplainAction::Diff => {
2920                let secs = params.seconds.unwrap_or(10);
2921                let since = chrono::Utc::now()
2922                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2923                let events = self.state.event_log.since(since);
2924
2925                let mut ipc_commands: Vec<String> = Vec::new();
2926                let mut dom_changes = 0u64;
2927                let mut error_count = 0u64;
2928                let mut interaction_count = 0u64;
2929                let mut console_messages = 0u64;
2930
2931                for event in &events {
2932                    if event.is_internal() {
2933                        continue;
2934                    }
2935                    match event {
2936                        victauri_core::AppEvent::Ipc(call) => {
2937                            ipc_commands.push(call.command.clone());
2938                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2939                                error_count += 1;
2940                            }
2941                        }
2942                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2943                            dom_changes += u64::from(*mutation_count)
2944                        }
2945                        victauri_core::AppEvent::DomInteraction { .. } => {
2946                            interaction_count += 1;
2947                        }
2948                        victauri_core::AppEvent::Console { level, .. } => {
2949                            console_messages += 1;
2950                            if level == "error" {
2951                                error_count += 1;
2952                            }
2953                        }
2954                        _ => {}
2955                    }
2956                }
2957
2958                ipc_commands.dedup();
2959
2960                let result = serde_json::json!({
2961                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2962                    "time_window_secs": secs,
2963                    "total_events": events.len(),
2964                    "ipc_calls_made": ipc_commands.len(),
2965                    "unique_commands": ipc_commands,
2966                    "dom_elements_changed": dom_changes,
2967                    "interactions": interaction_count,
2968                    "console_messages": console_messages,
2969                    "errors": error_count,
2970                });
2971                json_result(&result)
2972            }
2973        }
2974    }
2975}
2976
2977impl VictauriMcpHandler {
2978    /// Create a new handler backed by the given state and webview bridge.
2979    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2980        Self {
2981            state,
2982            bridge,
2983            subscriptions: Arc::new(Mutex::new(HashSet::new())),
2984            bridge_checked: Arc::new(AtomicBool::new(false)),
2985            probed_labels: Arc::new(Mutex::new(HashSet::new())),
2986            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
2987        }
2988    }
2989
2990    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2991        self.state.privacy.is_tool_enabled(name)
2992    }
2993
2994    pub(crate) async fn execute_tool(
2995        &self,
2996        name: &str,
2997        args: serde_json::Value,
2998    ) -> Result<CallToolResult, rest::ToolCallError> {
2999        if !self.state.privacy.is_tool_enabled(name) {
3000            return Ok(tool_disabled(name));
3001        }
3002        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3003        let start = std::time::Instant::now();
3004        tracing::debug!(tool = %name, "REST tool invocation started");
3005
3006        let result = match name {
3007            "eval_js" => {
3008                let p: EvalJsParams = Self::parse_args(args)?;
3009                self.eval_js(Parameters(p)).await
3010            }
3011            "dom_snapshot" => {
3012                let p: SnapshotParams = Self::parse_args(args)?;
3013                self.dom_snapshot(Parameters(p)).await
3014            }
3015            "find_elements" => {
3016                let p: FindElementsParams = Self::parse_args(args)?;
3017                self.find_elements(Parameters(p)).await
3018            }
3019            "invoke_command" => {
3020                let p: InvokeCommandParams = Self::parse_args(args)?;
3021                self.invoke_command(Parameters(p)).await
3022            }
3023            "screenshot" => {
3024                let p: ScreenshotParams = Self::parse_args(args)?;
3025                self.screenshot(Parameters(p)).await
3026            }
3027            "verify_state" => {
3028                let p: VerifyStateParams = Self::parse_args(args)?;
3029                self.verify_state(Parameters(p)).await
3030            }
3031            "detect_ghost_commands" => {
3032                let p: GhostCommandParams = Self::parse_args(args)?;
3033                self.detect_ghost_commands(Parameters(p)).await
3034            }
3035            "check_ipc_integrity" => {
3036                let p: IpcIntegrityParams = Self::parse_args(args)?;
3037                self.check_ipc_integrity(Parameters(p)).await
3038            }
3039            "wait_for" => {
3040                let p: WaitForParams = Self::parse_args(args)?;
3041                self.wait_for(Parameters(p)).await
3042            }
3043            "assert_semantic" => {
3044                let p: SemanticAssertParams = Self::parse_args(args)?;
3045                self.assert_semantic(Parameters(p)).await
3046            }
3047            "resolve_command" => {
3048                let p: ResolveCommandParams = Self::parse_args(args)?;
3049                self.resolve_command(Parameters(p)).await
3050            }
3051            "get_registry" => {
3052                let p: RegistryParams = Self::parse_args(args)?;
3053                self.get_registry(Parameters(p)).await
3054            }
3055            "get_memory_stats" => self.get_memory_stats().await,
3056            "get_plugin_info" => self.get_plugin_info().await,
3057            "get_diagnostics" => {
3058                let p: DiagnosticsParams = Self::parse_args(args)?;
3059                self.get_diagnostics(Parameters(p)).await
3060            }
3061            "app_info" => self.app_info().await,
3062            "list_app_dir" => {
3063                let p: ListAppDirParams = Self::parse_args(args)?;
3064                self.list_app_dir(Parameters(p)).await
3065            }
3066            "read_app_file" => {
3067                let p: ReadAppFileParams = Self::parse_args(args)?;
3068                self.read_app_file(Parameters(p)).await
3069            }
3070            #[cfg(feature = "sqlite")]
3071            "query_db" => {
3072                let p: QueryDbParams = Self::parse_args(args)?;
3073                self.query_db(Parameters(p)).await
3074            }
3075            "interact" => {
3076                let p: InteractParams = Self::parse_args(args)?;
3077                self.interact(Parameters(p)).await
3078            }
3079            "input" => {
3080                let p: InputParams = Self::parse_args(args)?;
3081                self.input(Parameters(p)).await
3082            }
3083            "window" => {
3084                let p: WindowParams = Self::parse_args(args)?;
3085                self.window(Parameters(p)).await
3086            }
3087            "storage" => {
3088                let p: StorageParams = Self::parse_args(args)?;
3089                self.storage(Parameters(p)).await
3090            }
3091            "navigate" => {
3092                let p: NavigateParams = Self::parse_args(args)?;
3093                self.navigate(Parameters(p)).await
3094            }
3095            "recording" => {
3096                let p: RecordingParams = Self::parse_args(args)?;
3097                self.recording(Parameters(p)).await
3098            }
3099            "inspect" => {
3100                let p: InspectParams = Self::parse_args(args)?;
3101                self.inspect(Parameters(p)).await
3102            }
3103            "css" => {
3104                let p: CssParams = Self::parse_args(args)?;
3105                self.css(Parameters(p)).await
3106            }
3107            "route" => {
3108                let p: RouteParams = Self::parse_args(args)?;
3109                self.route(Parameters(p)).await
3110            }
3111            "trace" => {
3112                let p: TraceParams = Self::parse_args(args)?;
3113                self.trace(Parameters(p)).await
3114            }
3115            "logs" => {
3116                let p: LogsParams = Self::parse_args(args)?;
3117                self.logs(Parameters(p)).await
3118            }
3119            "introspect" => {
3120                let p: IntrospectParams = Self::parse_args(args)?;
3121                self.introspect(Parameters(p)).await
3122            }
3123            "fault" => {
3124                let p: FaultParams = Self::parse_args(args)?;
3125                self.fault(Parameters(p)).await
3126            }
3127            "explain" => {
3128                let p: ExplainParams = Self::parse_args(args)?;
3129                self.explain(Parameters(p)).await
3130            }
3131            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3132        };
3133
3134        let elapsed = start.elapsed();
3135        tracing::debug!(
3136            tool = %name,
3137            elapsed_ms = elapsed.as_millis() as u64,
3138            "REST tool invocation completed"
3139        );
3140
3141        if self.state.privacy.redaction_enabled {
3142            Ok(Self::redact_result(result, &self.state.privacy))
3143        } else {
3144            Ok(result)
3145        }
3146    }
3147
3148    fn parse_args<T: serde::de::DeserializeOwned>(
3149        args: serde_json::Value,
3150    ) -> Result<T, rest::ToolCallError> {
3151        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3152    }
3153
3154    fn redact_result(
3155        mut result: CallToolResult,
3156        privacy: &crate::privacy::PrivacyConfig,
3157    ) -> CallToolResult {
3158        for item in &mut result.content {
3159            if let RawContent::Text(ref mut tc) = item.raw {
3160                tc.text = privacy.redact_output(&tc.text);
3161            }
3162        }
3163        result
3164    }
3165
3166    fn track_tool_call(&self) {
3167        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3168    }
3169
3170    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3171        match dir.unwrap_or(AppDir::Data) {
3172            AppDir::Data => self.bridge.app_data_dir(),
3173            AppDir::Config => self.bridge.app_config_dir(),
3174            AppDir::Log => self.bridge.app_log_dir(),
3175            AppDir::LocalData => self.bridge.app_local_data_dir(),
3176        }
3177    }
3178
3179    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3180        let canon_base = std::fs::canonicalize(base)
3181            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3182        let canon_target = std::fs::canonicalize(target)
3183            .map_err(|e| format!("cannot resolve target path: {e}"))?;
3184        if !canon_target.starts_with(&canon_base) {
3185            return Err("path traversal not allowed".to_string());
3186        }
3187        Ok(())
3188    }
3189
3190    fn list_dir_recursive(
3191        dir: &std::path::Path,
3192        base: &std::path::Path,
3193        depth: u32,
3194        max_depth: u32,
3195        pattern: Option<&str>,
3196        entries: &mut Vec<serde_json::Value>,
3197    ) {
3198        let Ok(read_dir) = std::fs::read_dir(dir) else {
3199            return;
3200        };
3201        for entry in read_dir.flatten() {
3202            let path = entry.path();
3203            if path.is_symlink() {
3204                continue;
3205            }
3206            let name = entry.file_name().to_string_lossy().into_owned();
3207            let relative = path
3208                .strip_prefix(base)
3209                .unwrap_or(&path)
3210                .to_string_lossy()
3211                .into_owned();
3212
3213            if let Some(pat) = pattern
3214                && !Self::matches_glob(&name, pat)
3215                && !path.is_dir()
3216            {
3217                continue;
3218            }
3219
3220            let is_dir = path.is_dir();
3221            let meta = std::fs::metadata(&path).ok();
3222
3223            entries.push(serde_json::json!({
3224                "name": name,
3225                "path": relative,
3226                "is_dir": is_dir,
3227                "size": meta.as_ref().map(std::fs::Metadata::len),
3228                "modified": meta.as_ref()
3229                    .and_then(|m| m.modified().ok())
3230                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3231                        .unwrap_or_default().as_secs()),
3232            }));
3233
3234            if is_dir && depth < max_depth {
3235                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3236            }
3237        }
3238    }
3239
3240    fn matches_glob(name: &str, pattern: &str) -> bool {
3241        if pattern == "*" {
3242            return true;
3243        }
3244        if let Some(suffix) = pattern.strip_prefix("*.") {
3245            return name.ends_with(&format!(".{suffix}"));
3246        }
3247        if let Some(prefix) = pattern.strip_suffix("*") {
3248            return name.starts_with(prefix);
3249        }
3250        name == pattern
3251    }
3252
3253    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3254        match self.eval_with_return(code, webview_label).await {
3255            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3256            Err(e) => tool_error(e),
3257        }
3258    }
3259
3260    async fn eval_with_return(
3261        &self,
3262        code: &str,
3263        webview_label: Option<&str>,
3264    ) -> Result<String, String> {
3265        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3266            .await
3267    }
3268
3269    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3270        let id = uuid::Uuid::new_v4().to_string();
3271        let (tx, rx) = tokio::sync::oneshot::channel();
3272        {
3273            let mut pending = self.state.pending_evals.lock().await;
3274            pending.insert(id.clone(), tx);
3275        }
3276        let id_js = js_string(&id);
3277        let probe = format!(
3278            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3279        );
3280        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3281            self.state.pending_evals.lock().await.remove(&id);
3282            return Err(format!("eval injection failed: {e}"));
3283        }
3284        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3285            Ok(())
3286        } else {
3287            self.state.pending_evals.lock().await.remove(&id);
3288            let label = webview_label.unwrap_or("default");
3289            Err(format!(
3290                "bridge not responding on window '{label}' — the window may be hidden, \
3291                 missing the victauri capability, or the JS bridge is not loaded"
3292            ))
3293        }
3294    }
3295
3296    async fn eval_with_return_timeout(
3297        &self,
3298        code: &str,
3299        webview_label: Option<&str>,
3300        timeout: std::time::Duration,
3301    ) -> Result<String, String> {
3302        self.track_tool_call();
3303
3304        // Wait for the JS bridge ready signal (sent on bridge init) before
3305        // attempting evals.  For explicitly targeted windows the probe
3306        // mechanism is still used because the ready signal only proves that
3307        // *some* webview's bridge loaded — not necessarily the targeted one.
3308        if !self
3309            .state
3310            .bridge_ready
3311            .load(std::sync::atomic::Ordering::Acquire)
3312        {
3313            let notified = self.state.bridge_notify.notified();
3314            if !self
3315                .state
3316                .bridge_ready
3317                .load(std::sync::atomic::Ordering::Acquire)
3318            {
3319                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3320            }
3321        }
3322
3323        // Reserved sentinel key for the default (unlabeled) window — cannot
3324        // collide with a real label.
3325        let label_key =
3326            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3327
3328        // Proactively probe explicitly-targeted windows once (cached), so a
3329        // hidden/unready window fails fast rather than after the full timeout.
3330        if webview_label.is_some() {
3331            let already_probed = self.probed_labels.lock().await.contains(&label_key);
3332            if !already_probed {
3333                self.probe_bridge(webview_label).await?;
3334                self.probed_labels.lock().await.insert(label_key.clone());
3335            }
3336        }
3337
3338        // Resilience: if the PREVIOUS eval on this window timed out, the bridge
3339        // may have gone away (the webview reloaded or the app crashed). Do a
3340        // fast liveness probe so this call fails in ~2s with a clear error
3341        // instead of blocking the full timeout again. If the bridge is alive
3342        // (the earlier timeout was slow code / an infinite loop), the probe
3343        // succeeds quickly and we proceed normally.
3344        if self.timed_out_labels.lock().await.remove(&label_key) {
3345            self.probe_bridge(webview_label).await.map_err(|e| {
3346                format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3347            })?;
3348        }
3349
3350        let id = uuid::Uuid::new_v4().to_string();
3351        let (tx, rx) = tokio::sync::oneshot::channel();
3352
3353        {
3354            let mut pending = self.state.pending_evals.lock().await;
3355            if pending.len() >= MAX_PENDING_EVALS {
3356                return Err(format!(
3357                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3358                ));
3359            }
3360            pending.insert(id.clone(), tx);
3361        }
3362
3363        // Auto-prepend `return` so bare expressions produce a value — but ONLY
3364        // for single expressions. Multi-statement blocks (or code containing an
3365        // explicit `return`) are used as-is. Prepending `return` to a statement
3366        // block like `foo(); return bar()` would parse as `return foo();` and
3367        // silently discard everything after the first statement (issue: core
3368        // primitive returned wrong/undefined values for "do X, then return Y").
3369        let code = if should_prepend_return(code) {
3370            format!("return {}", code.trim())
3371        } else {
3372            code.trim().to_string()
3373        };
3374
3375        let id_js = js_string(&id);
3376        let inject = format!(
3377            r"
3378            (async () => {{
3379                try {{
3380                    const __result = await (async () => {{ {code} }})();
3381                    const __type = __result === undefined ? 'undefined'
3382                        : __result === null ? 'null' : 'value';
3383                    const __val = __type === 'undefined' ? null
3384                        : __type === 'null' ? null : __result;
3385                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3386                        id: {id_js},
3387                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3388                    }});
3389                }} catch (e) {{
3390                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3391                        id: {id_js},
3392                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3393                    }});
3394                }}
3395            }})();
3396            "
3397        );
3398
3399        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3400            self.state.pending_evals.lock().await.remove(&id);
3401            return Err(format!("eval injection failed: {e}"));
3402        }
3403
3404        match tokio::time::timeout(timeout, rx).await {
3405            Ok(Ok(raw)) => {
3406                self.check_bridge_version_once();
3407                if raw.len() > MAX_EVAL_RESULT_LEN {
3408                    return Err(format!(
3409                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3410                        raw.len()
3411                    ));
3412                }
3413                unwrap_eval_envelope(raw)
3414            }
3415            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3416            Err(_) => {
3417                self.state.pending_evals.lock().await.remove(&id);
3418                // Mark this window so the NEXT eval does a fast liveness probe —
3419                // if the bridge is gone (reloaded/crashed) the next call fails in
3420                // ~2s instead of blocking the full timeout again.
3421                self.timed_out_labels.lock().await.insert(label_key.clone());
3422                Err(format!(
3423                    "eval timed out after {}s — the code never resolved. Common causes: a \
3424                     JavaScript syntax error in the injected code (parse errors cannot be \
3425                     reported by the webview and surface only as this timeout), an unresolved \
3426                     promise, an infinite loop, or the webview reloaded / the app stopped \
3427                     responding mid-eval. Verify the code parses and resolves; if the app may \
3428                     have navigated or crashed, retry (the next call fails fast if the bridge \
3429                     is gone).",
3430                    timeout.as_secs()
3431                ))
3432            }
3433        }
3434    }
3435
3436    #[cfg(feature = "sqlite")]
3437    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3438        // Roots: configured db_search_paths first, then app directories.
3439        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3440        for d in [
3441            self.bridge.app_data_dir(),
3442            self.bridge.app_local_data_dir(),
3443            self.bridge.app_config_dir(),
3444        ]
3445        .into_iter()
3446        .flatten()
3447        {
3448            roots.push(d);
3449        }
3450
3451        let path = if let Some(p) = db_path {
3452            let candidate = std::path::Path::new(p);
3453            if candidate.is_absolute() {
3454                if !roots
3455                    .iter()
3456                    .any(|r| Self::safe_within(r, candidate).is_ok())
3457                {
3458                    return Err(format!(
3459                        "absolute path '{p}' is not within an allowed directory; \
3460                         register its parent via VictauriBuilder::db_search_paths"
3461                    ));
3462                }
3463                candidate.to_path_buf()
3464            } else {
3465                roots
3466                    .iter()
3467                    .map(|r| r.join(p))
3468                    .find(|c| c.exists())
3469                    .ok_or_else(|| format!("database not found: {p}"))?
3470            }
3471        } else {
3472            roots
3473                .iter()
3474                .flat_map(|r| crate::database::discover_databases(r))
3475                .next()
3476                .ok_or_else(|| {
3477                    "no database found in app directories or configured db_search_paths".to_string()
3478                })?
3479        };
3480        // No further containment check needed: the path is either discovered
3481        // within an allowed root, an existing relative file joined onto an
3482        // allowed root, or an absolute path already verified above. (A
3483        // safe_within check against app_data_dir would fail when that directory
3484        // does not exist — common for apps that store data elsewhere.)
3485        let path_str = path
3486            .to_str()
3487            .ok_or_else(|| "invalid path encoding".to_string())?
3488            .to_string();
3489
3490        tokio::task::spawn_blocking(move || {
3491            let conn = rusqlite::Connection::open_with_flags(
3492                &path_str,
3493                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3494            )
3495            .map_err(|e| format!("cannot open database: {e}"))?;
3496
3497            let journal_mode: String = conn
3498                .pragma_query_value(None, "journal_mode", |r| r.get(0))
3499                .unwrap_or_else(|_| "unknown".to_string());
3500
3501            let page_count: i64 = conn
3502                .pragma_query_value(None, "page_count", |r| r.get(0))
3503                .unwrap_or(0);
3504
3505            let page_size: i64 = conn
3506                .pragma_query_value(None, "page_size", |r| r.get(0))
3507                .unwrap_or(0);
3508
3509            let freelist_count: i64 = conn
3510                .pragma_query_value(None, "freelist_count", |r| r.get(0))
3511                .unwrap_or(0);
3512
3513            let wal_checkpoint: String = if journal_mode == "wal" {
3514                let mut info = String::from("n/a");
3515                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3516                    let busy: i64 = r.get(0)?;
3517                    let checkpointed: i64 = r.get(1)?;
3518                    let total: i64 = r.get(2)?;
3519                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3520                    Ok(())
3521                });
3522                info
3523            } else {
3524                "n/a (not WAL mode)".to_string()
3525            };
3526
3527            let integrity: String = conn
3528                .pragma_query_value(None, "quick_check", |r| r.get(0))
3529                .unwrap_or_else(|_| "failed".to_string());
3530
3531            let db_size_bytes = page_count * page_size;
3532            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3533
3534            let mut tables = Vec::new();
3535            if let Ok(mut stmt) =
3536                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3537                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3538            {
3539                for name in rows.flatten() {
3540                    let count: i64 = conn
3541                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3542                        .unwrap_or(0);
3543                    tables.push(serde_json::json!({
3544                        "name": name,
3545                        "row_count": count,
3546                    }));
3547                }
3548            }
3549
3550            Ok(serde_json::json!({
3551                "database": path_str,
3552                "journal_mode": journal_mode,
3553                "page_count": page_count,
3554                "page_size": page_size,
3555                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3556                "freelist_count": freelist_count,
3557                "wal_checkpoint": wal_checkpoint,
3558                "integrity_check": integrity,
3559                "tables": tables,
3560            }))
3561        })
3562        .await
3563        .map_err(|e| format!("db health task failed: {e}"))?
3564    }
3565
3566    fn check_bridge_version_once(&self) {
3567        if self.bridge_checked.swap(true, Ordering::Relaxed) {
3568            return;
3569        }
3570        let handler = self.clone();
3571        tokio::spawn(async move {
3572            match handler
3573                .eval_with_return_timeout(
3574                    "window.__VICTAURI__?.version",
3575                    None,
3576                    std::time::Duration::from_secs(5),
3577                )
3578                .await
3579            {
3580                Ok(v) => {
3581                    let v = v.trim_matches('"');
3582                    if v == BRIDGE_VERSION {
3583                        tracing::debug!("Bridge version verified: {v}");
3584                    } else {
3585                        tracing::warn!(
3586                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3587                        );
3588                    }
3589                }
3590                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3591            }
3592        });
3593    }
3594}
3595
3596const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3597It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3598(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3599(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3600\n\nBACKEND tools (direct Rust access, no webview needed): \
3601'app_info' (app config, directory paths, discovered databases, process info), \
3602'list_app_dir' (browse app data/config/log directories), \
3603'read_app_file' (read files from app directories), \
3604'query_db' (read-only SQLite queries with auto-discovery). \
3605\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3606'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3607capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3608Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3609capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3610task tracking, and automatic Tauri event bus monitoring. \
3611'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3612drops, and response corruption into Tauri commands at the Rust layer. \
3613'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3614activity across IPC + DOM + console + network + window events into a coherent narrative. \
3615\n\nWEBVIEW tools: \
3616'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3617'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3618'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3619\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3620\n\nCOMPOUND tools with an 'action' parameter: \
3621'window' (get_state, list, manage, resize, move_to, set_title), \
3622'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3623set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3624get_events, events_between, get_replay, export, import, replay), \
3625'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3626\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3627get_memory_stats, get_plugin_info, get_diagnostics.";
3628
3629impl ServerHandler for VictauriMcpHandler {
3630    fn get_info(&self) -> ServerInfo {
3631        ServerInfo::new(
3632            ServerCapabilities::builder()
3633                .enable_tools()
3634                .enable_resources()
3635                .enable_resources_subscribe()
3636                .build(),
3637        )
3638        .with_instructions(SERVER_INSTRUCTIONS)
3639    }
3640
3641    async fn list_tools(
3642        &self,
3643        _request: Option<PaginatedRequestParams>,
3644        _context: RequestContext<RoleServer>,
3645    ) -> Result<ListToolsResult, ErrorData> {
3646        let all_tools = Self::tool_router().list_all();
3647        let filtered: Vec<Tool> = all_tools
3648            .into_iter()
3649            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3650            .collect();
3651        Ok(ListToolsResult {
3652            tools: filtered,
3653            ..Default::default()
3654        })
3655    }
3656
3657    async fn call_tool(
3658        &self,
3659        request: CallToolRequestParams,
3660        context: RequestContext<RoleServer>,
3661    ) -> Result<CallToolResult, ErrorData> {
3662        let tool_name: String = request.name.as_ref().to_owned();
3663        if !self.state.privacy.is_tool_enabled(&tool_name) {
3664            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3665            return Ok(tool_disabled(&tool_name));
3666        }
3667        self.state
3668            .tool_invocations
3669            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3670        let start = std::time::Instant::now();
3671        tracing::debug!(tool = %tool_name, "tool invocation started");
3672        let ctx = ToolCallContext::new(self, request, context);
3673        let result = Self::tool_router().call(ctx).await;
3674        let elapsed = start.elapsed();
3675        tracing::debug!(
3676            tool = %tool_name,
3677            elapsed_ms = elapsed.as_millis() as u64,
3678            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3679            "tool invocation completed"
3680        );
3681
3682        // Centralized output redaction: apply to all text content so no
3683        // individual tool can accidentally leak secrets.
3684        if self.state.privacy.redaction_enabled {
3685            result.map(|mut r| {
3686                for item in &mut r.content {
3687                    if let RawContent::Text(ref mut tc) = item.raw {
3688                        tc.text = self.state.privacy.redact_output(&tc.text);
3689                    }
3690                }
3691                r
3692            })
3693        } else {
3694            result
3695        }
3696    }
3697
3698    fn get_tool(&self, name: &str) -> Option<Tool> {
3699        if !self.state.privacy.is_tool_enabled(name) {
3700            return None;
3701        }
3702        Self::tool_router().get(name).cloned()
3703    }
3704
3705    async fn list_resources(
3706        &self,
3707        _request: Option<PaginatedRequestParams>,
3708        _context: RequestContext<RoleServer>,
3709    ) -> Result<ListResourcesResult, ErrorData> {
3710        Ok(ListResourcesResult {
3711            resources: vec![
3712                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3713                    .with_description(
3714                        "Live IPC call log — all commands invoked between frontend and backend",
3715                    )
3716                    .with_mime_type("application/json")
3717                    .no_annotation(),
3718                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3719                    .with_description(
3720                        "Current state of all Tauri windows — position, size, visibility, focus",
3721                    )
3722                    .with_mime_type("application/json")
3723                    .no_annotation(),
3724                RawResource::new(RESOURCE_URI_STATE, "state")
3725                    .with_description(
3726                        "Victauri plugin state — event count, registered commands, memory stats",
3727                    )
3728                    .with_mime_type("application/json")
3729                    .no_annotation(),
3730            ],
3731            ..Default::default()
3732        })
3733    }
3734
3735    async fn read_resource(
3736        &self,
3737        request: ReadResourceRequestParams,
3738        _context: RequestContext<RoleServer>,
3739    ) -> Result<ReadResourceResult, ErrorData> {
3740        let uri = &request.uri;
3741        let json = match uri.as_str() {
3742            RESOURCE_URI_IPC_LOG => {
3743                if let Ok(json) = self
3744                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3745                    .await
3746                {
3747                    json
3748                } else {
3749                    let calls = self.state.event_log.ipc_calls();
3750                    serde_json::to_string_pretty(&calls)
3751                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3752                }
3753            }
3754            RESOURCE_URI_WINDOWS => {
3755                let states = self.bridge.get_window_states(None);
3756                serde_json::to_string_pretty(&states)
3757                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3758            }
3759            RESOURCE_URI_STATE => {
3760                let state_json = serde_json::json!({
3761                    "events_captured": self.state.event_log.len(),
3762                    "commands_registered": self.state.registry.count(),
3763                    "memory": crate::memory::current_stats(),
3764                    "port": self.state.port.load(Ordering::Relaxed),
3765                });
3766                serde_json::to_string_pretty(&state_json)
3767                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3768            }
3769            _ => {
3770                return Err(ErrorData::resource_not_found(
3771                    format!("unknown resource: {uri}"),
3772                    None,
3773                ));
3774            }
3775        };
3776
3777        let json = if self.state.privacy.redaction_enabled {
3778            self.state.privacy.redact_output(&json)
3779        } else {
3780            json
3781        };
3782
3783        Ok(ReadResourceResult::new(vec![ResourceContents::text(
3784            json, uri,
3785        )]))
3786    }
3787
3788    async fn subscribe(
3789        &self,
3790        request: SubscribeRequestParams,
3791        _context: RequestContext<RoleServer>,
3792    ) -> Result<(), ErrorData> {
3793        let uri = &request.uri;
3794        match uri.as_str() {
3795            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3796                self.subscriptions.lock().await.insert(uri.clone());
3797                tracing::info!("Client subscribed to resource: {uri}");
3798                Ok(())
3799            }
3800            _ => Err(ErrorData::resource_not_found(
3801                format!("unknown resource: {uri}"),
3802                None,
3803            )),
3804        }
3805    }
3806
3807    async fn unsubscribe(
3808        &self,
3809        request: UnsubscribeRequestParams,
3810        _context: RequestContext<RoleServer>,
3811    ) -> Result<(), ErrorData> {
3812        self.subscriptions.lock().await.remove(&request.uri);
3813        tracing::info!("Client unsubscribed from resource: {}", request.uri);
3814        Ok(())
3815    }
3816}
3817
3818/// Build a JS expression that takes an array of log entries (`source_expr`),
3819/// keeps at most `limit` of the most recent, and truncates any per-entry field
3820/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
3821/// the eval size cap on busy apps where individual entries carry large bodies.
3822///
3823/// The returned code is a complete `return (...)` statement.
3824fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
3825    let mb = MAX_LOG_FIELD_BYTES;
3826    format!(
3827        r"return (function() {{
3828            var MB = {mb};
3829            function trimField(v) {{
3830                if (typeof v === 'string') {{
3831                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
3832                }}
3833                if (v && typeof v === 'object') {{
3834                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
3835                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
3836                }}
3837                return v;
3838            }}
3839            function trimEntry(e) {{
3840                if (e == null || typeof e !== 'object') return e;
3841                var out = Array.isArray(e) ? [] : {{}};
3842                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
3843                return out;
3844            }}
3845            var arr = {source_expr} || [];
3846            if (arr.length > {limit}) arr = arr.slice(-{limit});
3847            return arr.map(trimEntry);
3848        }})()"
3849    )
3850}
3851
3852/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
3853/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
3854/// value/error string returned to callers.
3855///
3856/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
3857/// disabled — an unbounded recursive parse of a pathologically deep result
3858/// overflows the worker thread stack and crashes the host). When the parse
3859/// fails because the value is too deeply nested, the envelope is stripped by
3860/// string slicing (no recursion) so the actual value is still returned rather
3861/// than leaking the raw envelope string.
3862fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
3863    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
3864        if let Some(err) = envelope.get("__victauri_err") {
3865            return Err(format!(
3866                "JavaScript error: {}",
3867                err.as_str().unwrap_or("unknown error")
3868            ));
3869        }
3870        if envelope.get("__victauri_ok").is_some() {
3871            let js_type = envelope
3872                .get("__victauri_type")
3873                .and_then(|t| t.as_str())
3874                .unwrap_or("value");
3875            return match js_type {
3876                "undefined" => Ok("undefined".to_string()),
3877                "null" => Ok("null".to_string()),
3878                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
3879                    .unwrap_or_else(|_| "null".to_string())),
3880            };
3881        }
3882    }
3883    // Fallback for results too deeply nested for the recursion-limited parser.
3884    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
3885        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
3886    {
3887        return Ok(after[..idx].to_string());
3888    }
3889    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
3890        let msg = after.trim_end_matches('}').trim_matches('"');
3891        return Err(format!("JavaScript error: {msg}"));
3892    }
3893    Ok(raw)
3894}
3895
3896/// Statement keywords where a leading `return` would be a syntax error.
3897const STMT_STARTS: &[&str] = &[
3898    "return ",
3899    "return;",
3900    "return\n",
3901    "return\t",
3902    "if ",
3903    "if(",
3904    "for ",
3905    "for(",
3906    "while ",
3907    "while(",
3908    "switch ",
3909    "switch(",
3910    "try ",
3911    "try{",
3912    "const ",
3913    "let ",
3914    "var ",
3915    "function ",
3916    "function(",
3917    "function*",
3918    "class ",
3919    "throw ",
3920    "do ",
3921    "do{",
3922    "{",
3923    "async function",
3924    "debugger",
3925];
3926
3927/// String/template/comment scan state for [`should_prepend_return`].
3928#[derive(PartialEq, Clone, Copy)]
3929enum ScanState {
3930    Code,
3931    SingleQuote,
3932    DoubleQuote,
3933    Template,
3934}
3935
3936/// Decide whether to wrap `code` with a leading `return`.
3937///
3938/// Only a single bare expression should get `return` prepended. Code that is a
3939/// multi-statement block, contains an explicit top-level `return`, or starts
3940/// with a statement keyword is used as-is — prepending `return` to such code
3941/// would execute only the first statement and silently discard the rest.
3942///
3943/// The scan is string/template/comment-aware and only treats a `;` or an
3944/// explicit `return` token as significant when it occurs at bracket depth 0
3945/// outside of any string, template literal, or comment.
3946fn should_prepend_return(code: &str) -> bool {
3947    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
3948
3949    let code = code.trim();
3950    if code.is_empty() {
3951        return false;
3952    }
3953
3954    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
3955        return false;
3956    }
3957
3958    let bytes = code.as_bytes();
3959    let mut i = 0;
3960    let mut depth: i32 = 0;
3961    let mut state = ScanState::Code;
3962
3963    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
3964    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
3965    let is_return_token = |i: usize| -> bool {
3966        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
3967        prev_ok
3968            && code[i..].starts_with("return")
3969            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
3970    };
3971
3972    while i < bytes.len() {
3973        let c = bytes[i];
3974        match state {
3975            Code => match c {
3976                b'\'' => state = SingleQuote,
3977                b'"' => state = DoubleQuote,
3978                b'`' => state = Template,
3979                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
3980                    while i < bytes.len() && bytes[i] != b'\n' {
3981                        i += 1;
3982                    }
3983                    continue;
3984                }
3985                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
3986                    i += 2;
3987                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
3988                        i += 1;
3989                    }
3990                    i += 2;
3991                    continue;
3992                }
3993                b'(' | b'[' | b'{' => depth += 1,
3994                b')' | b']' | b'}' => depth -= 1,
3995                // A top-level `;` with more code after it == multi-statement.
3996                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
3997                // An explicit top-level `return` token means the code already returns.
3998                b'r' if depth <= 0 && is_return_token(i) => return false,
3999                _ => {}
4000            },
4001            SingleQuote => {
4002                if c == b'\\' {
4003                    i += 1;
4004                } else if c == b'\'' {
4005                    state = Code;
4006                }
4007            }
4008            DoubleQuote => {
4009                if c == b'\\' {
4010                    i += 1;
4011                } else if c == b'"' {
4012                    state = Code;
4013                }
4014            }
4015            Template => {
4016                if c == b'\\' {
4017                    i += 1;
4018                } else if c == b'`' {
4019                    state = Code;
4020                }
4021            }
4022        }
4023        i += 1;
4024    }
4025
4026    true
4027}
4028
4029#[cfg(test)]
4030mod prop_tests {
4031    //! Property-based tests for the eval auto-return heuristic — the code that
4032    //! caused the worst bug in the system (silent corruption of multi-statement
4033    //! eval) and has bitten twice. These generate many JS-ish snippets and
4034    //! assert the invariants that keep eval correct.
4035    use super::should_prepend_return;
4036    use proptest::prelude::*;
4037
4038    /// A small set of non-keyword identifier-ish expressions.
4039    fn ident() -> impl Strategy<Value = String> {
4040        prop_oneof![
4041            Just("a".to_string()),
4042            Just("x".to_string()),
4043            Just("foo".to_string()),
4044            Just("window.x".to_string()),
4045            Just("document.title".to_string()),
4046            Just("obj.prop".to_string()),
4047            Just("arr[0]".to_string()),
4048            Just("localStorage".to_string()),
4049        ]
4050    }
4051
4052    /// A single bare expression: never starts with a statement keyword, has no
4053    /// top-level `;`, and contains no `return`.
4054    fn bare_expr() -> impl Strategy<Value = String> {
4055        prop_oneof![
4056            ident(),
4057            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4058            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4059            ident().prop_map(|a| format!("{a}.length")),
4060            any::<u16>().prop_map(|n| n.to_string()),
4061        ]
4062    }
4063
4064    proptest! {
4065        /// Must never panic or hang on ANY input — including malformed code,
4066        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
4067        #[test]
4068        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4069            let _ = should_prepend_return(&s);
4070        }
4071
4072        /// A single bare expression is safe to wrap with `return` → true.
4073        #[test]
4074        fn bare_expressions_are_prepended(e in bare_expr()) {
4075            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4076        }
4077
4078        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
4079        /// (else `return <expr>;` runs and the rest is silently discarded).
4080        #[test]
4081        fn semicolon_multistatement_with_return_never_prepended(
4082            setup in bare_expr(), ret in bare_expr()
4083        ) {
4084            let code = format!("{setup}; return {ret}");
4085            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4086        }
4087
4088        /// Newline-separated (ASI) explicit return must also be left as-is.
4089        #[test]
4090        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4091            let code = format!("{pre}\nreturn {ret}");
4092            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4093        }
4094
4095        /// `;` or the word `return` INSIDE a string literal must not trigger a
4096        /// false multi-statement split — a bare string is one expression.
4097        #[test]
4098        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4099            // `inner` never contains a quote, so the literal is well-formed.
4100            let code = format!("'do;not;split return {inner}'");
4101            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4102        }
4103    }
4104}
4105
4106#[cfg(test)]
4107mod tests {
4108    use super::*;
4109
4110    #[test]
4111    fn prepend_return_bare_expressions() {
4112        assert!(should_prepend_return("document.title"));
4113        assert!(should_prepend_return("5 + 5"));
4114        assert!(should_prepend_return("\"justexpr\""));
4115        assert!(should_prepend_return("await fetch('/x')"));
4116        assert!(should_prepend_return(
4117            "document.querySelectorAll('a').length"
4118        ));
4119        assert!(should_prepend_return("x ? a : b"));
4120        // Single trailing semicolon on a bare expression is still an expression.
4121        assert!(should_prepend_return("document.title;"));
4122        // Semicolons inside strings must not be treated as boundaries.
4123        assert!(should_prepend_return("'a;b;c'"));
4124        assert!(should_prepend_return("\"x;y\".length"));
4125        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
4126        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4127    }
4128
4129    #[test]
4130    fn no_prepend_for_statement_blocks() {
4131        // The original silent-corruption cases.
4132        assert!(!should_prepend_return(
4133            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4134        ));
4135        assert!(!should_prepend_return(
4136            "window.scrollTo(0,50); return window.scrollY"
4137        ));
4138        assert!(!should_prepend_return("console.log('x'); return 123"));
4139        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4140        // Explicit return without a preceding semicolon (newline-separated).
4141        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4142    }
4143
4144    #[test]
4145    fn no_prepend_for_statement_keywords() {
4146        assert!(!should_prepend_return("return 42"));
4147        assert!(!should_prepend_return("const x = 1; return x"));
4148        assert!(!should_prepend_return("let y = 2"));
4149        assert!(!should_prepend_return("var z = 3"));
4150        assert!(!should_prepend_return("if (x) { return 1 }"));
4151        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4152        assert!(!should_prepend_return("throw new Error('x')"));
4153        assert!(!should_prepend_return("function f(){}"));
4154        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
4155    }
4156
4157    #[test]
4158    fn empty_code_no_prepend() {
4159        assert!(!should_prepend_return(""));
4160        assert!(!should_prepend_return("   "));
4161    }
4162
4163    #[test]
4164    fn envelope_unwrap_value() {
4165        assert_eq!(
4166            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4167            Ok("\"4DA\"".to_string())
4168        );
4169        assert_eq!(
4170            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4171            Ok("42".to_string())
4172        );
4173    }
4174
4175    #[test]
4176    fn envelope_unwrap_undefined_null() {
4177        assert_eq!(
4178            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4179            Ok("undefined".to_string())
4180        );
4181        assert_eq!(
4182            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4183            Ok("null".to_string())
4184        );
4185    }
4186
4187    #[test]
4188    fn envelope_unwrap_error() {
4189        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4190        assert!(r.unwrap_err().contains("boom"));
4191    }
4192
4193    #[test]
4194    fn envelope_unwrap_deeply_nested_does_not_leak() {
4195        // Build an envelope whose value is nested far deeper than serde_json's
4196        // default recursion limit (128). The full parse fails, so the slice
4197        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
4198        let mut value = String::from("0");
4199        for _ in 0..300 {
4200            value = format!("{{\"n\":{value}}}");
4201        }
4202        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4203        let out = unwrap_eval_envelope(raw).unwrap();
4204        assert!(
4205            out.starts_with(r#"{"n":"#),
4206            "deep value should be unwrapped, got: {}",
4207            &out[..out.len().min(40)]
4208        );
4209        assert!(
4210            !out.contains("__victauri_ok"),
4211            "envelope must not leak into the result"
4212        );
4213    }
4214
4215    #[test]
4216    fn js_string_simple() {
4217        assert_eq!(js_string("hello"), "\"hello\"");
4218    }
4219
4220    #[test]
4221    fn js_string_single_quotes() {
4222        let result = js_string("it's a test");
4223        assert!(result.contains("it's a test"));
4224    }
4225
4226    #[test]
4227    fn js_string_double_quotes() {
4228        let result = js_string(r#"say "hello""#);
4229        assert!(result.contains(r#"\""#));
4230    }
4231
4232    #[test]
4233    fn js_string_backslashes() {
4234        let result = js_string(r"path\to\file");
4235        assert!(result.contains(r"\\"));
4236    }
4237
4238    #[test]
4239    fn js_string_newlines_and_tabs() {
4240        let result = js_string("line1\nline2\ttab");
4241        assert!(result.contains(r"\n"));
4242        assert!(result.contains(r"\t"));
4243        assert!(!result.contains('\n'));
4244    }
4245
4246    #[test]
4247    fn js_string_null_bytes() {
4248        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4249        let result = js_string(&input);
4250        // serde_json escapes null bytes as
4251        assert!(result.contains("\\u0000"));
4252        assert!(!result.contains('\0'));
4253    }
4254
4255    #[test]
4256    fn js_string_template_literal_injection() {
4257        let result = js_string("`${alert(1)}`");
4258        // Should not contain unescaped backticks that could break template literals
4259        // serde_json wraps in double quotes, so backticks are safe
4260        assert!(result.starts_with('"'));
4261        assert!(result.ends_with('"'));
4262    }
4263
4264    #[test]
4265    fn js_string_unicode_separators() {
4266        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
4267        // JSON strings per RFC 8259, and serde_json passes them through literally.
4268        // Since js_string is used inside JS double-quoted strings (not template
4269        // literals), they are safe in modern JS engines (ES2019+).
4270        let result = js_string("a\u{2028}b\u{2029}c");
4271        // Verify the string is valid JSON that round-trips correctly
4272        let decoded: String = serde_json::from_str(&result).unwrap();
4273        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4274    }
4275
4276    #[test]
4277    fn js_string_empty() {
4278        assert_eq!(js_string(""), "\"\"");
4279    }
4280
4281    #[test]
4282    fn js_string_html_script_close() {
4283        // </script> in a JS string inside HTML could break out of script tags
4284        let result = js_string("</script><img onerror=alert(1)>");
4285        assert!(result.starts_with('"'));
4286        // The string is JSON-encoded; verify it round-trips safely
4287        let decoded: String = serde_json::from_str(&result).unwrap();
4288        assert_eq!(decoded, "</script><img onerror=alert(1)>");
4289    }
4290
4291    #[test]
4292    fn js_string_very_long() {
4293        let long = "a".repeat(100_000);
4294        let result = js_string(&long);
4295        assert!(result.len() >= 100_002); // quotes + content
4296    }
4297
4298    // ── URL validation tests ────────────────────────────────────────────────
4299
4300    #[test]
4301    fn url_allows_http() {
4302        assert!(validate_url("http://example.com", false).is_ok());
4303    }
4304
4305    #[test]
4306    fn url_allows_https() {
4307        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
4308    }
4309
4310    #[test]
4311    fn url_allows_http_localhost() {
4312        assert!(validate_url("http://localhost:3000", false).is_ok());
4313    }
4314
4315    #[test]
4316    fn url_blocks_file_by_default() {
4317        let err = validate_url("file:///etc/passwd", false).unwrap_err();
4318        assert!(err.contains("file"), "error should mention the file scheme");
4319    }
4320
4321    #[test]
4322    fn url_allows_file_when_opted_in() {
4323        assert!(validate_url("file:///tmp/test.html", true).is_ok());
4324    }
4325
4326    #[test]
4327    fn url_blocks_javascript() {
4328        assert!(validate_url("javascript:alert(1)", false).is_err());
4329    }
4330
4331    #[test]
4332    fn url_blocks_javascript_case_insensitive() {
4333        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
4334    }
4335
4336    #[test]
4337    fn url_blocks_data_scheme() {
4338        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
4339    }
4340
4341    #[test]
4342    fn url_blocks_vbscript() {
4343        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
4344    }
4345
4346    #[test]
4347    fn url_rejects_invalid() {
4348        assert!(validate_url("not a url at all", false).is_err());
4349    }
4350
4351    #[test]
4352    fn url_strips_control_chars() {
4353        // Control characters should be stripped, leaving a valid URL
4354        let input = format!("http://example{}com", '\0');
4355        assert!(validate_url(&input, false).is_ok());
4356    }
4357
4358    // ── CSS color sanitization tests ───────────────────────────────────────
4359
4360    #[test]
4361    fn css_color_valid_hex() {
4362        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
4363        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
4364        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
4365    }
4366
4367    #[test]
4368    fn css_color_valid_rgb() {
4369        assert_eq!(
4370            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
4371            "rgb(255, 0, 0)"
4372        );
4373        assert_eq!(
4374            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
4375            "rgba(0, 0, 0, 0.5)"
4376        );
4377    }
4378
4379    #[test]
4380    fn css_color_valid_named() {
4381        assert_eq!(sanitize_css_color("red").unwrap(), "red");
4382        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
4383    }
4384
4385    #[test]
4386    fn css_color_valid_hsl() {
4387        assert_eq!(
4388            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
4389            "hsl(120, 50%, 50%)"
4390        );
4391    }
4392
4393    #[test]
4394    fn css_color_rejects_too_long() {
4395        let long = "a".repeat(101);
4396        assert!(sanitize_css_color(&long).is_err());
4397    }
4398
4399    #[test]
4400    fn css_color_rejects_backslash_escapes() {
4401        assert!(sanitize_css_color(r"red\00").is_err());
4402        assert!(sanitize_css_color(r"\72\65\64").is_err());
4403    }
4404
4405    #[test]
4406    fn css_color_rejects_url_injection() {
4407        assert!(sanitize_css_color("url(http://evil.com)").is_err());
4408        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4409    }
4410
4411    #[test]
4412    fn css_color_rejects_expression_injection() {
4413        assert!(sanitize_css_color("expression(alert(1))").is_err());
4414        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4415    }
4416
4417    #[test]
4418    fn css_color_rejects_import() {
4419        assert!(sanitize_css_color("@import url(evil.css)").is_err());
4420    }
4421
4422    #[test]
4423    fn css_color_rejects_semicolons_and_braces() {
4424        assert!(sanitize_css_color("red; background: url(evil)").is_err());
4425        assert!(sanitize_css_color("red} body { color: blue").is_err());
4426    }
4427
4428    #[test]
4429    fn css_color_rejects_special_chars() {
4430        assert!(sanitize_css_color("red<script>").is_err());
4431        assert!(sanitize_css_color("red\"onload=alert").is_err());
4432        assert!(sanitize_css_color("red'onclick=alert").is_err());
4433    }
4434
4435    #[test]
4436    fn css_color_trims_whitespace() {
4437        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
4438    }
4439
4440    #[test]
4441    fn css_color_empty_string() {
4442        assert_eq!(sanitize_css_color("").unwrap(), "");
4443    }
4444}