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