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                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1087                self.eval_bridge(&code, params.webview_label.as_deref())
1088                    .await
1089            }
1090            InteractAction::DoubleClick => {
1091                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1092                    return tool_disabled("interact.double_click");
1093                }
1094                let Some(ref_id) = &params.ref_id else {
1095                    return missing_param("ref_id", "double_click");
1096                };
1097                let code = format!(
1098                    "return window.__VICTAURI__?.doubleClick({})",
1099                    js_string(ref_id)
1100                );
1101                self.eval_bridge(&code, params.webview_label.as_deref())
1102                    .await
1103            }
1104            InteractAction::Hover => {
1105                if !self.state.privacy.is_tool_enabled("interact.hover") {
1106                    return tool_disabled("interact.hover");
1107                }
1108                let Some(ref_id) = &params.ref_id else {
1109                    return missing_param("ref_id", "hover");
1110                };
1111                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1112                self.eval_bridge(&code, params.webview_label.as_deref())
1113                    .await
1114            }
1115            InteractAction::Focus => {
1116                if !self.state.privacy.is_tool_enabled("interact.focus") {
1117                    return tool_disabled("interact.focus");
1118                }
1119                let Some(ref_id) = &params.ref_id else {
1120                    return missing_param("ref_id", "focus");
1121                };
1122                let code = format!(
1123                    "return window.__VICTAURI__?.focusElement({})",
1124                    js_string(ref_id)
1125                );
1126                self.eval_bridge(&code, params.webview_label.as_deref())
1127                    .await
1128            }
1129            InteractAction::ScrollIntoView => {
1130                if !self
1131                    .state
1132                    .privacy
1133                    .is_tool_enabled("interact.scroll_into_view")
1134                {
1135                    return tool_disabled("interact.scroll_into_view");
1136                }
1137                let ref_arg = params
1138                    .ref_id
1139                    .as_ref()
1140                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1141                let x = params.x.unwrap_or(0.0);
1142                let y = params.y.unwrap_or(0.0);
1143                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1144                self.eval_bridge(&code, params.webview_label.as_deref())
1145                    .await
1146            }
1147            InteractAction::SelectOption => {
1148                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1149                    return tool_disabled("interact.select_option");
1150                }
1151                let Some(ref_id) = &params.ref_id else {
1152                    return missing_param("ref_id", "select_option");
1153                };
1154                let values_vec;
1155                let values: &[String] = match (&params.values, &params.value) {
1156                    (Some(v), _) => v,
1157                    (None, Some(v)) => {
1158                        values_vec = vec![v.clone()];
1159                        &values_vec
1160                    }
1161                    (None, None) => &[],
1162                };
1163                let values_json =
1164                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1165                let code = format!(
1166                    "return window.__VICTAURI__?.selectOption({}, {})",
1167                    js_string(ref_id),
1168                    values_json
1169                );
1170                self.eval_bridge(&code, params.webview_label.as_deref())
1171                    .await
1172            }
1173        }
1174    }
1175
1176    #[tool(
1177        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.",
1178        annotations(
1179            read_only_hint = false,
1180            destructive_hint = false,
1181            idempotent_hint = false,
1182            open_world_hint = false
1183        )
1184    )]
1185    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1186        match params.action {
1187            InputAction::Fill => {
1188                if !self.state.privacy.is_tool_enabled("fill") {
1189                    return tool_disabled("fill");
1190                }
1191                let Some(ref_id) = &params.ref_id else {
1192                    return missing_param("ref_id", "fill");
1193                };
1194                let Some(value) = &params.value else {
1195                    return missing_param("value", "fill");
1196                };
1197                let code = format!(
1198                    "return window.__VICTAURI__?.fill({}, {})",
1199                    js_string(ref_id),
1200                    js_string(value)
1201                );
1202                self.eval_bridge(&code, params.webview_label.as_deref())
1203                    .await
1204            }
1205            InputAction::TypeText => {
1206                if !self.state.privacy.is_tool_enabled("type_text") {
1207                    return tool_disabled("type_text");
1208                }
1209                let Some(ref_id) = &params.ref_id else {
1210                    return missing_param("ref_id", "type_text");
1211                };
1212                let Some(text) = &params.text else {
1213                    return missing_param("text", "type_text");
1214                };
1215                let code = format!(
1216                    "return window.__VICTAURI__?.type({}, {})",
1217                    js_string(ref_id),
1218                    js_string(text)
1219                );
1220                self.eval_bridge(&code, params.webview_label.as_deref())
1221                    .await
1222            }
1223            InputAction::PressKey => {
1224                if !self.state.privacy.is_tool_enabled("input.press_key") {
1225                    return tool_disabled("input.press_key");
1226                }
1227                let Some(key) = &params.key else {
1228                    return missing_param("key", "press_key");
1229                };
1230                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1231                self.eval_bridge(&code, params.webview_label.as_deref())
1232                    .await
1233            }
1234        }
1235    }
1236
1237    #[tool(
1238        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.",
1239        annotations(
1240            read_only_hint = false,
1241            destructive_hint = false,
1242            idempotent_hint = true,
1243            open_world_hint = false
1244        )
1245    )]
1246    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1247        self.track_tool_call();
1248        match params.action {
1249            WindowAction::GetState => {
1250                let states = self.bridge.get_window_states(params.label.as_deref());
1251                // A specific label that matches no window is an error, not an
1252                // empty array (which reads as "success, no state").
1253                if states.is_empty()
1254                    && let Some(label) = params.label.as_deref()
1255                {
1256                    return tool_error(format!(
1257                        "window not found: '{label}' (use window.list to see available labels)"
1258                    ));
1259                }
1260                json_result(&states)
1261            }
1262            WindowAction::List => {
1263                let labels = self.bridge.list_window_labels();
1264                json_result(&labels)
1265            }
1266            WindowAction::Manage => {
1267                if !self.state.privacy.is_tool_enabled("window.manage") {
1268                    return tool_disabled("window.manage");
1269                }
1270                let Some(manage_action) = &params.manage_action else {
1271                    return missing_param("manage_action", "manage");
1272                };
1273                match self
1274                    .bridge
1275                    .manage_window(params.label.as_deref(), manage_action.as_str())
1276                {
1277                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1278                    Err(e) => tool_error(e),
1279                }
1280            }
1281            WindowAction::Resize => {
1282                if !self.state.privacy.is_tool_enabled("window.resize") {
1283                    return tool_disabled("window.resize");
1284                }
1285                let Some(width) = params.width else {
1286                    return missing_param("width", "resize");
1287                };
1288                let Some(height) = params.height else {
1289                    return missing_param("height", "resize");
1290                };
1291                if width == 0 || height == 0 {
1292                    return tool_error_with_hint(
1293                        format!(
1294                            "invalid window size {width}x{height}: width and height must be > 0"
1295                        ),
1296                        RecoveryHint::CheckInput,
1297                    );
1298                }
1299                match self
1300                    .bridge
1301                    .resize_window(params.label.as_deref(), width, height)
1302                {
1303                    Ok(()) => {
1304                        let result =
1305                            serde_json::json!({"ok": true, "width": width, "height": height});
1306                        CallToolResult::success(vec![Content::text(result.to_string())])
1307                    }
1308                    Err(e) => tool_error(e),
1309                }
1310            }
1311            WindowAction::MoveTo => {
1312                if !self.state.privacy.is_tool_enabled("window.move_to") {
1313                    return tool_disabled("window.move_to");
1314                }
1315                let Some(x) = params.x else {
1316                    return missing_param("x", "move_to");
1317                };
1318                let Some(y) = params.y else {
1319                    return missing_param("y", "move_to");
1320                };
1321                match self.bridge.move_window(params.label.as_deref(), x, y) {
1322                    Ok(()) => {
1323                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1324                        CallToolResult::success(vec![Content::text(result.to_string())])
1325                    }
1326                    Err(e) => tool_error(e),
1327                }
1328            }
1329            WindowAction::SetTitle => {
1330                if !self.state.privacy.is_tool_enabled("window.set_title") {
1331                    return tool_disabled("window.set_title");
1332                }
1333                let Some(title) = &params.title else {
1334                    return missing_param("title", "set_title");
1335                };
1336                match self.bridge.set_window_title(params.label.as_deref(), title) {
1337                    Ok(()) => {
1338                        let result = serde_json::json!({"ok": true, "title": title});
1339                        CallToolResult::success(vec![Content::text(result.to_string())])
1340                    }
1341                    Err(e) => tool_error(e),
1342                }
1343            }
1344        }
1345    }
1346
1347    #[tool(
1348        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1349        annotations(
1350            read_only_hint = false,
1351            destructive_hint = true,
1352            idempotent_hint = false,
1353            open_world_hint = false
1354        )
1355    )]
1356    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1357        match params.action {
1358            StorageAction::Get => {
1359                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1360                    StorageType::Session => "getSessionStorage",
1361                    StorageType::Local => "getLocalStorage",
1362                };
1363                let key_arg = params
1364                    .key
1365                    .as_ref()
1366                    .map(|k| js_string(k))
1367                    .unwrap_or_default();
1368                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1369                self.eval_bridge(&code, params.webview_label.as_deref())
1370                    .await
1371            }
1372            StorageAction::Set => {
1373                if !self.state.privacy.is_tool_enabled("set_storage") {
1374                    return tool_disabled("set_storage");
1375                }
1376                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1377                    StorageType::Session => "setSessionStorage",
1378                    StorageType::Local => "setLocalStorage",
1379                };
1380                let Some(key) = &params.key else {
1381                    return missing_param("key", "set");
1382                };
1383                let value = params
1384                    .value
1385                    .as_ref()
1386                    .cloned()
1387                    .unwrap_or(serde_json::Value::Null);
1388                let value_json =
1389                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1390                let code = format!(
1391                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1392                    js_string(key)
1393                );
1394                self.eval_bridge(&code, params.webview_label.as_deref())
1395                    .await
1396            }
1397            StorageAction::Delete => {
1398                if !self.state.privacy.is_tool_enabled("delete_storage") {
1399                    return tool_disabled("delete_storage");
1400                }
1401                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1402                    StorageType::Session => "deleteSessionStorage",
1403                    StorageType::Local => "deleteLocalStorage",
1404                };
1405                let Some(key) = &params.key else {
1406                    return missing_param("key", "delete");
1407                };
1408                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1409                self.eval_bridge(&code, params.webview_label.as_deref())
1410                    .await
1411            }
1412            StorageAction::GetCookies => {
1413                self.eval_bridge(
1414                    "return window.__VICTAURI__?.getCookies()",
1415                    params.webview_label.as_deref(),
1416                )
1417                .await
1418            }
1419        }
1420    }
1421
1422    #[tool(
1423        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.",
1424        annotations(
1425            read_only_hint = false,
1426            destructive_hint = false,
1427            idempotent_hint = false,
1428            open_world_hint = false
1429        )
1430    )]
1431    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1432        match params.action {
1433            NavigateAction::GoTo => {
1434                if !self.state.privacy.is_tool_enabled("navigate") {
1435                    return tool_disabled("navigate");
1436                }
1437                let Some(url) = &params.url else {
1438                    return missing_param("url", "go_to");
1439                };
1440                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1441                    return tool_error(e);
1442                }
1443                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1444                self.eval_bridge(&code, params.webview_label.as_deref())
1445                    .await
1446            }
1447            NavigateAction::GoBack => {
1448                self.eval_bridge(
1449                    "return window.__VICTAURI__?.navigateBack()",
1450                    params.webview_label.as_deref(),
1451                )
1452                .await
1453            }
1454            NavigateAction::GetHistory => {
1455                self.eval_bridge(
1456                    "return window.__VICTAURI__?.getNavigationLog()",
1457                    params.webview_label.as_deref(),
1458                )
1459                .await
1460            }
1461            NavigateAction::SetDialogResponse => {
1462                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1463                    return tool_disabled("set_dialog_response");
1464                }
1465                let Some(dialog_type) = params.dialog_type else {
1466                    return missing_param("dialog_type", "set_dialog_response");
1467                };
1468                let Some(dialog_action) = params.dialog_action else {
1469                    return missing_param("dialog_action", "set_dialog_response");
1470                };
1471                let text_arg = params
1472                    .text
1473                    .as_ref()
1474                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1475                let code = format!(
1476                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1477                    js_string(dialog_type.as_str()),
1478                    js_string(dialog_action.as_str())
1479                );
1480                self.eval_bridge(&code, params.webview_label.as_deref())
1481                    .await
1482            }
1483            NavigateAction::GetDialogLog => {
1484                self.eval_bridge(
1485                    "return window.__VICTAURI__?.getDialogLog()",
1486                    params.webview_label.as_deref(),
1487                )
1488                .await
1489            }
1490        }
1491    }
1492
1493    #[tool(
1494        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).",
1495        annotations(
1496            read_only_hint = false,
1497            destructive_hint = false,
1498            idempotent_hint = false,
1499            open_world_hint = false
1500        )
1501    )]
1502    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1503        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1504        self.track_tool_call();
1505        if !self.state.privacy.is_tool_enabled("recording") {
1506            return tool_disabled("recording");
1507        }
1508        match params.action {
1509            RecordingAction::Start => {
1510                let session_id = params
1511                    .session_id
1512                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1513                match self.state.recorder.start(session_id.clone()) {
1514                    Ok(()) => {
1515                        let result = serde_json::json!({
1516                            "started": true,
1517                            "session_id": session_id,
1518                        });
1519                        CallToolResult::success(vec![Content::text(result.to_string())])
1520                    }
1521                    Err(e) => tool_error(e.to_string()),
1522                }
1523            }
1524            RecordingAction::Stop => match self.state.recorder.stop() {
1525                Some(session) => json_result(&session),
1526                None => tool_error("no recording is active"),
1527            },
1528            RecordingAction::Checkpoint => {
1529                let Some(id) = params.checkpoint_id else {
1530                    return missing_param("checkpoint_id", "checkpoint");
1531                };
1532                let state = params.state.unwrap_or(serde_json::Value::Null);
1533                match self
1534                    .state
1535                    .recorder
1536                    .checkpoint(id.clone(), params.checkpoint_label, state)
1537                {
1538                    Ok(()) => {
1539                        let result = serde_json::json!({
1540                            "created": true,
1541                            "checkpoint_id": id,
1542                            "event_index": self.state.recorder.event_count(),
1543                        });
1544                        CallToolResult::success(vec![Content::text(result.to_string())])
1545                    }
1546                    Err(e) => tool_error(e.to_string()),
1547                }
1548            }
1549            RecordingAction::ListCheckpoints => {
1550                let checkpoints = self.state.recorder.get_checkpoints();
1551                json_result(&checkpoints)
1552            }
1553            RecordingAction::GetEvents => {
1554                let events = self
1555                    .state
1556                    .recorder
1557                    .events_since(params.since_index.unwrap_or(0));
1558                json_result(&events)
1559            }
1560            RecordingAction::EventsBetween => {
1561                let Some(from) = &params.from else {
1562                    return missing_param("from", "events_between");
1563                };
1564                let Some(to) = &params.to else {
1565                    return missing_param("to", "events_between");
1566                };
1567                match self.state.recorder.events_between_checkpoints(from, to) {
1568                    Ok(events) => json_result(&events),
1569                    Err(e) => tool_error(e.to_string()),
1570                }
1571            }
1572            RecordingAction::GetReplay => {
1573                let calls = self.state.recorder.ipc_replay_sequence();
1574                json_result(&calls)
1575            }
1576            RecordingAction::Export => match self.state.recorder.export() {
1577                Some(s) => {
1578                    let json = serde_json::to_string_pretty(&s)
1579                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1580                    CallToolResult::success(vec![Content::text(json)])
1581                }
1582                None => tool_error("no recording is active — start one first"),
1583            },
1584            RecordingAction::Import => {
1585                let Some(session_json) = &params.session_json else {
1586                    return missing_param("session_json", "import");
1587                };
1588                if session_json.len() > MAX_SESSION_JSON {
1589                    return tool_error("session JSON exceeds maximum size (10 MB)");
1590                }
1591                let session: victauri_core::RecordedSession =
1592                    match serde_json::from_str(session_json) {
1593                        Ok(s) => s,
1594                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1595                    };
1596
1597                let result = serde_json::json!({
1598                    "imported": true,
1599                    "session_id": session.id,
1600                    "event_count": session.events.len(),
1601                    "checkpoint_count": session.checkpoints.len(),
1602                    "started_at": session.started_at.to_rfc3339(),
1603                });
1604                self.state.recorder.import(session);
1605                CallToolResult::success(vec![Content::text(result.to_string())])
1606            }
1607            RecordingAction::Flush => {
1608                if !self.state.recorder.is_recording() {
1609                    return tool_error("no active recording — start a recording first");
1610                }
1611                let code = "return window.__VICTAURI__?.getEventStream(0)";
1612                match self
1613                    .eval_with_return(code, params.webview_label.as_deref())
1614                    .await
1615                {
1616                    Ok(result_str) => {
1617                        let events: Vec<serde_json::Value> =
1618                            serde_json::from_str(&result_str).unwrap_or_default();
1619                        let mut count = 0u64;
1620                        for ev in &events {
1621                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1622                                self.state.event_log.push(app_event.clone());
1623                                self.state.recorder.record_event(app_event);
1624                                count += 1;
1625                            }
1626                        }
1627                        json_result(&serde_json::json!({
1628                            "flushed": true,
1629                            "events_captured": count,
1630                        }))
1631                    }
1632                    Err(e) => tool_error(format!("flush failed: {e}")),
1633                }
1634            }
1635            RecordingAction::Replay => {
1636                let calls = self.state.recorder.ipc_replay_sequence();
1637                if calls.is_empty() {
1638                    return tool_error("no IPC calls recorded — record a session first");
1639                }
1640                let mut replay_results = Vec::new();
1641                for call in &calls {
1642                    let code = format!(
1643                        "return window.__TAURI_INTERNALS__.invoke({})",
1644                        js_string(&call.command)
1645                    );
1646                    let outcome = match self
1647                        .eval_with_return(&code, params.webview_label.as_deref())
1648                        .await
1649                    {
1650                        Ok(result_str) => {
1651                            let value: serde_json::Value = serde_json::from_str(&result_str)
1652                                .unwrap_or(serde_json::Value::String(result_str));
1653                            let shape = crate::introspection::JsonShape::from_value(&value);
1654                            serde_json::json!({
1655                                "command": call.command,
1656                                "status": "ok",
1657                                "response_type": shape.type_name(),
1658                            })
1659                        }
1660                        Err(e) => {
1661                            serde_json::json!({
1662                                "command": call.command,
1663                                "status": "error",
1664                                "error": e,
1665                            })
1666                        }
1667                    };
1668                    replay_results.push(outcome);
1669                }
1670                let passed = replay_results
1671                    .iter()
1672                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1673                    .count();
1674                let result = serde_json::json!({
1675                    "replayed": replay_results.len(),
1676                    "passed": passed,
1677                    "failed": replay_results.len() - passed,
1678                    "results": replay_results,
1679                });
1680                json_result(&result)
1681            }
1682        }
1683    }
1684
1685    #[tool(
1686        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).",
1687        annotations(
1688            read_only_hint = true,
1689            destructive_hint = false,
1690            idempotent_hint = true,
1691            open_world_hint = false
1692        )
1693    )]
1694    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1695        match params.action {
1696            InspectAction::GetStyles => {
1697                let Some(ref_id) = &params.ref_id else {
1698                    return missing_param("ref_id", "get_styles");
1699                };
1700                let props_arg = match &params.properties {
1701                    Some(props) => {
1702                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1703                        format!("[{}]", arr.join(","))
1704                    }
1705                    None => "null".to_string(),
1706                };
1707                let code = format!(
1708                    "return window.__VICTAURI__?.getStyles({}, {})",
1709                    js_string(ref_id),
1710                    props_arg
1711                );
1712                self.eval_bridge(&code, params.webview_label.as_deref())
1713                    .await
1714            }
1715            InspectAction::GetBoundingBoxes => {
1716                let Some(ref_ids) = &params.ref_ids else {
1717                    return missing_param("ref_ids", "get_bounding_boxes");
1718                };
1719                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1720                let code = format!(
1721                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1722                    refs.join(",")
1723                );
1724                self.eval_bridge(&code, params.webview_label.as_deref())
1725                    .await
1726            }
1727            InspectAction::Highlight => {
1728                let Some(ref_id) = &params.ref_id else {
1729                    return missing_param("ref_id", "highlight");
1730                };
1731                let color_arg = match &params.color {
1732                    Some(c) => match sanitize_css_color(c) {
1733                        Ok(safe) => format!("\"{safe}\""),
1734                        Err(e) => return tool_error(e),
1735                    },
1736                    None => "null".to_string(),
1737                };
1738                let label_arg = match &params.label {
1739                    Some(l) => js_string(l),
1740                    None => "null".to_string(),
1741                };
1742                let code = format!(
1743                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1744                    js_string(ref_id),
1745                    color_arg,
1746                    label_arg
1747                );
1748                self.eval_bridge(&code, params.webview_label.as_deref())
1749                    .await
1750            }
1751            InspectAction::ClearHighlights => {
1752                self.eval_bridge(
1753                    "return window.__VICTAURI__?.clearHighlights()",
1754                    params.webview_label.as_deref(),
1755                )
1756                .await
1757            }
1758            InspectAction::AuditAccessibility => {
1759                self.eval_bridge(
1760                    "return window.__VICTAURI__?.auditAccessibility()",
1761                    params.webview_label.as_deref(),
1762                )
1763                .await
1764            }
1765            InspectAction::GetPerformance => {
1766                self.eval_bridge(
1767                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1768                    params.webview_label.as_deref(),
1769                )
1770                .await
1771            }
1772        }
1773    }
1774
1775    #[tool(
1776        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1777        annotations(
1778            read_only_hint = false,
1779            destructive_hint = false,
1780            idempotent_hint = true,
1781            open_world_hint = false
1782        )
1783    )]
1784    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1785        match params.action {
1786            CssAction::Inject => {
1787                if !self.state.privacy.is_tool_enabled("inject_css") {
1788                    return tool_disabled("inject_css");
1789                }
1790                let Some(css) = &params.css else {
1791                    return missing_param("css", "inject");
1792                };
1793                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1794                self.eval_bridge(&code, params.webview_label.as_deref())
1795                    .await
1796            }
1797            CssAction::Remove => {
1798                if !self.state.privacy.is_tool_enabled("css.remove") {
1799                    return tool_disabled("css.remove");
1800                }
1801                self.eval_bridge(
1802                    "return window.__VICTAURI__?.removeInjectedCss()",
1803                    params.webview_label.as_deref(),
1804                )
1805                .await
1806            }
1807        }
1808    }
1809
1810    #[tool(
1811        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).",
1812        annotations(
1813            read_only_hint = true,
1814            destructive_hint = false,
1815            idempotent_hint = true,
1816            open_world_hint = false
1817        )
1818    )]
1819    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1820        match params.action {
1821            LogsAction::Console => {
1822                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1823                let base = if since_arg.is_empty() {
1824                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
1825                } else {
1826                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
1827                };
1828                let code = if let Some(limit) = params.limit {
1829                    format!("return ({base} || []).slice(-{limit})")
1830                } else {
1831                    format!("return {base}")
1832                };
1833                self.eval_bridge(&code, params.webview_label.as_deref())
1834                    .await
1835            }
1836            LogsAction::Network => {
1837                let filter_arg = params
1838                    .filter
1839                    .as_ref()
1840                    .map_or_else(|| "null".to_string(), |f| js_string(f));
1841                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
1842                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
1843                let code = trimmed_log_js(&source, limit);
1844                self.eval_bridge(&code, params.webview_label.as_deref())
1845                    .await
1846            }
1847            LogsAction::Ipc => {
1848                let wait = params.wait_for_capture.unwrap_or(false);
1849                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
1850                if wait {
1851                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
1852                    let code = format!(
1853                        r"return (async function() {{
1854                            await window.__VICTAURI__.waitForIpcComplete(500);
1855                            return (function() {{ {inner} }})();
1856                        }})()"
1857                    );
1858                    let timeout = std::time::Duration::from_millis(5000);
1859                    match self
1860                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1861                        .await
1862                    {
1863                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1864                        Err(e) => tool_error(e),
1865                    }
1866                } else {
1867                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
1868                    self.eval_bridge(&code, params.webview_label.as_deref())
1869                        .await
1870                }
1871            }
1872            LogsAction::Navigation => {
1873                let code = if let Some(limit) = params.limit {
1874                    format!(
1875                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
1876                    )
1877                } else {
1878                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
1879                };
1880                self.eval_bridge(&code, params.webview_label.as_deref())
1881                    .await
1882            }
1883            LogsAction::Dialogs => {
1884                let code = if let Some(limit) = params.limit {
1885                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
1886                } else {
1887                    "return window.__VICTAURI__?.getDialogLog()".to_string()
1888                };
1889                self.eval_bridge(&code, params.webview_label.as_deref())
1890                    .await
1891            }
1892            LogsAction::Events => {
1893                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1894                let base = if since_arg.is_empty() {
1895                    "window.__VICTAURI__?.getEventStream()".to_string()
1896                } else {
1897                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
1898                };
1899                let code = if let Some(limit) = params.limit {
1900                    format!("return ({base} || []).slice(-{limit})")
1901                } else {
1902                    format!("return {base}")
1903                };
1904                self.eval_bridge(&code, params.webview_label.as_deref())
1905                    .await
1906            }
1907            LogsAction::SlowIpc => {
1908                let Some(threshold) = params.threshold_ms else {
1909                    return missing_param("threshold_ms", "slow_ipc");
1910                };
1911                let limit = params.limit.unwrap_or(20);
1912                let mb = MAX_LOG_FIELD_BYTES;
1913                let code = format!(
1914                    r"return (function() {{
1915                        var MB = {mb};
1916                        function trimField(v) {{
1917                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
1918                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
1919                            return v;
1920                        }}
1921                        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; }}
1922                        var log = window.__VICTAURI__?.getIpcLog() || [];
1923                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1924                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1925                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
1926                    }})()",
1927                );
1928                self.eval_bridge(&code, None).await
1929            }
1930        }
1931    }
1932
1933    // ── Backend Introspection ────────────────────────────────────────────────
1934
1935    #[tool(
1936        description = "Deep backend introspection — command profiling, IPC contract testing, \
1937            coverage, startup timing, capability auditing, database diagnostics, process \
1938            enumeration, and event bus monitoring. \
1939            These features exploit Victauri's position inside the Rust process.\n\n\
1940            Actions:\n\
1941            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
1942            - `coverage`: Which registered commands have been called during this session.\n\
1943            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
1944            - `contract_check`: Check all recorded contracts for schema drift.\n\
1945            - `contract_list`: List all recorded contract baselines.\n\
1946            - `contract_clear`: Clear all recorded contract baselines.\n\
1947            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
1948            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
1949            - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
1950            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
1951            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
1952            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
1953            - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
1954            - `event_bus_clear`: Clear the event bus capture buffer.",
1955        annotations(
1956            read_only_hint = true,
1957            destructive_hint = false,
1958            idempotent_hint = true,
1959            open_world_hint = false
1960        )
1961    )]
1962    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
1963        self.track_tool_call();
1964        if !self.state.privacy.is_tool_enabled("introspect") {
1965            return tool_disabled("introspect");
1966        }
1967
1968        match params.action {
1969            IntrospectAction::CommandTimings => {
1970                let mut stats = self.state.command_timings.all_stats();
1971                if let Some(threshold) = params.slow_threshold_ms {
1972                    stats.retain(|s| s.avg_ms >= threshold);
1973                }
1974                let result = serde_json::json!({
1975                    "commands": stats,
1976                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
1977                    "slow_threshold_ms": params.slow_threshold_ms,
1978                });
1979                json_result(&result)
1980            }
1981            IntrospectAction::Coverage => {
1982                let registered: Vec<String> = self
1983                    .state
1984                    .registry
1985                    .list()
1986                    .iter()
1987                    .map(|c| c.name.clone())
1988                    .collect();
1989
1990                let code = "return window.__VICTAURI__?.getIpcLog()";
1991                let invoked: std::collections::HashSet<String> = match self
1992                    .eval_with_return(code, params.webview_label.as_deref())
1993                    .await
1994                {
1995                    Ok(json_str) => {
1996                        if let Ok(entries) =
1997                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
1998                        {
1999                            entries
2000                                .iter()
2001                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2002                                .map(String::from)
2003                                .collect()
2004                        } else {
2005                            std::collections::HashSet::new()
2006                        }
2007                    }
2008                    Err(_) => std::collections::HashSet::new(),
2009                };
2010
2011                let uncovered: Vec<&String> = registered
2012                    .iter()
2013                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2014                    .collect();
2015
2016                let coverage_pct = if registered.is_empty() {
2017                    100.0
2018                } else {
2019                    let covered = registered.len() - uncovered.len();
2020                    (covered as f64 / registered.len() as f64) * 100.0
2021                };
2022
2023                let result = serde_json::json!({
2024                    "registered_commands": registered.len(),
2025                    "invoked_commands": invoked.len(),
2026                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2027                    "uncovered": uncovered,
2028                    "invoked_not_registered": invoked.iter()
2029                        .filter(|cmd| !registered.contains(cmd))
2030                        .collect::<Vec<_>>(),
2031                });
2032                json_result(&result)
2033            }
2034            IntrospectAction::ContractRecord => {
2035                let Some(command) = params.command else {
2036                    return missing_param("command", "contract_record");
2037                };
2038                let args_json = params.args.unwrap_or(serde_json::json!({}));
2039                let args_str =
2040                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2041                let code = format!(
2042                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2043                    js_string(&command)
2044                );
2045                match self
2046                    .eval_with_return(&code, params.webview_label.as_deref())
2047                    .await
2048                {
2049                    Ok(result_str) => {
2050                        let value: serde_json::Value = serde_json::from_str(&result_str)
2051                            .unwrap_or(serde_json::Value::String(result_str.clone()));
2052                        let shape = crate::introspection::JsonShape::from_value(&value);
2053                        let sample = if result_str.len() > 4096 {
2054                            format!("{}...(truncated)", &result_str[..4096])
2055                        } else {
2056                            result_str
2057                        };
2058                        let baseline = crate::introspection::ContractBaseline {
2059                            command: command.clone(),
2060                            args: args_json,
2061                            shape: shape.clone(),
2062                            sample,
2063                            recorded_at: chrono_now(),
2064                        };
2065                        self.state.contract_store.record(baseline);
2066                        let result = serde_json::json!({
2067                            "recorded": true,
2068                            "command": command,
2069                            "shape_type": shape.type_name(),
2070                        });
2071                        json_result(&result)
2072                    }
2073                    Err(e) => tool_error(format!(
2074                        "failed to invoke '{command}' for contract recording: {e}"
2075                    )),
2076                }
2077            }
2078            IntrospectAction::ContractCheck => {
2079                let baselines = self.state.contract_store.all();
2080                if baselines.is_empty() {
2081                    return json_result(&serde_json::json!({
2082                        "checked": 0,
2083                        "message": "no contract baselines recorded — use contract_record first",
2084                    }));
2085                }
2086                let mut results = Vec::new();
2087                for baseline in &baselines {
2088                    let args_str =
2089                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2090                    let code = format!(
2091                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2092                        js_string(&baseline.command)
2093                    );
2094                    match self
2095                        .eval_with_return(&code, params.webview_label.as_deref())
2096                        .await
2097                    {
2098                        Ok(result_str) => {
2099                            let value: serde_json::Value = serde_json::from_str(&result_str)
2100                                .unwrap_or(serde_json::Value::String(result_str));
2101                            let current_shape = crate::introspection::JsonShape::from_value(&value);
2102                            let drift = crate::introspection::diff_shapes(
2103                                &baseline.shape,
2104                                &current_shape,
2105                                &baseline.command,
2106                            );
2107                            results.push(drift);
2108                        }
2109                        Err(e) => {
2110                            results.push(crate::introspection::ContractDrift {
2111                                command: baseline.command.clone(),
2112                                new_fields: Vec::new(),
2113                                removed_fields: Vec::new(),
2114                                type_changes: Vec::new(),
2115                                shape_matches: false,
2116                            });
2117                            tracing::warn!(
2118                                command = %baseline.command,
2119                                error = %e,
2120                                "contract check invocation failed"
2121                            );
2122                        }
2123                    }
2124                }
2125                let passing = results.iter().filter(|r| r.shape_matches).count();
2126                let result = serde_json::json!({
2127                    "checked": results.len(),
2128                    "passing": passing,
2129                    "failing": results.len() - passing,
2130                    "contracts": results,
2131                });
2132                json_result(&result)
2133            }
2134            IntrospectAction::ContractList => {
2135                let baselines = self.state.contract_store.all();
2136                let result = serde_json::json!({
2137                    "count": baselines.len(),
2138                    "baselines": baselines.iter().map(|b| serde_json::json!({
2139                        "command": b.command,
2140                        "shape_type": b.shape.type_name(),
2141                        "recorded_at": b.recorded_at,
2142                    })).collect::<Vec<_>>(),
2143                });
2144                json_result(&result)
2145            }
2146            IntrospectAction::ContractClear => {
2147                let cleared = self.state.contract_store.clear();
2148                json_result(&serde_json::json!({
2149                    "cleared": cleared,
2150                }))
2151            }
2152            IntrospectAction::StartupTiming => {
2153                let phases = self.state.startup_timeline.report();
2154                let result = serde_json::json!({
2155                    "phases": phases,
2156                    "total_ms": self.state.startup_timeline.total_ms(),
2157                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2158                });
2159                json_result(&result)
2160            }
2161            IntrospectAction::Capabilities => {
2162                let config = self.bridge.tauri_config();
2163                let live_windows = self.bridge.list_window_labels();
2164
2165                let result = serde_json::json!({
2166                    "app": {
2167                        "identifier": config.get("identifier"),
2168                        "product_name": config.get("product_name"),
2169                        "version": config.get("version"),
2170                    },
2171                    "security": config.get("security"),
2172                    "configured_windows": config.get("windows"),
2173                    "live_windows": live_windows,
2174                    "configured_plugins": config.get("plugins"),
2175                    "victauri": {
2176                        "registered_commands": self.state.registry.list().len(),
2177                        "auth_enabled": self.state.privacy.redaction_enabled,
2178                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
2179                        "disabled_tools": &self.state.privacy.disabled_tools,
2180                    },
2181                });
2182                json_result(&result)
2183            }
2184            #[allow(unused_variables)]
2185            IntrospectAction::DbHealth => {
2186                #[cfg(feature = "sqlite")]
2187                {
2188                    let db_path = params.db_path.clone();
2189                    match self.run_db_health(db_path.as_deref()).await {
2190                        Ok(health) => json_result(&health),
2191                        Err(e) => tool_error(format!("db_health failed: {e}")),
2192                    }
2193                }
2194                #[cfg(not(feature = "sqlite"))]
2195                {
2196                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2197                }
2198            }
2199            IntrospectAction::PluginState => {
2200                let recording_active = self.state.recorder.is_recording();
2201                let recording_events = self.state.recorder.event_count();
2202                let result = serde_json::json!({
2203                    "event_log": {
2204                        "size": self.state.event_log.len(),
2205                        "capacity": self.state.event_log.capacity(),
2206                    },
2207                    "registry": {
2208                        "commands_registered": self.state.registry.list().len(),
2209                    },
2210                    "recording": {
2211                        "active": recording_active,
2212                        "events_captured": recording_events,
2213                    },
2214                    "faults": {
2215                        "active_rules": self.state.fault_registry.list().len(),
2216                    },
2217                    "contracts": {
2218                        "baselines_recorded": self.state.contract_store.all().len(),
2219                    },
2220                    "timings": {
2221                        "commands_profiled": self.state.command_timings.all_stats().len(),
2222                    },
2223                    "event_bus": {
2224                        "captured_events": self.state.event_bus.len(),
2225                    },
2226                    "tasks": {
2227                        "total": self.state.task_tracker.list().len(),
2228                        "active": self.state.task_tracker.active_count(),
2229                    },
2230                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2231                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2232                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2233                });
2234                json_result(&result)
2235            }
2236            IntrospectAction::Processes => {
2237                let pid = std::process::id();
2238                let uptime = self.state.started_at.elapsed();
2239                let children = crate::introspection::enumerate_child_processes();
2240                let host_memory = crate::memory::current_stats();
2241
2242                let result = serde_json::json!({
2243                    "host": {
2244                        "pid": pid,
2245                        "uptime_secs": uptime.as_secs(),
2246                        "platform": std::env::consts::OS,
2247                        "arch": std::env::consts::ARCH,
2248                        "memory": host_memory,
2249                    },
2250                    "children": children.iter().map(|c| serde_json::json!({
2251                        "pid": c.pid,
2252                        "name": c.name,
2253                        "memory_bytes": c.memory_bytes,
2254                    })).collect::<Vec<_>>(),
2255                    "child_count": children.len(),
2256                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2257                });
2258                json_result(&result)
2259            }
2260            IntrospectAction::PluginTasks => {
2261                let tasks = self.state.task_tracker.list();
2262                let active = self.state.task_tracker.active_count();
2263                let result = serde_json::json!({
2264                    "total": tasks.len(),
2265                    "active": active,
2266                    "finished": tasks.len() - active,
2267                    "tasks": tasks,
2268                });
2269                json_result(&result)
2270            }
2271            IntrospectAction::EventBus => {
2272                let tauri_events = self.state.event_bus.events();
2273                let app_events = self.state.event_log.snapshot();
2274                let result = serde_json::json!({
2275                    "tauri_events": {
2276                        "count": tauri_events.len(),
2277                        "events": tauri_events,
2278                    },
2279                    "app_events": {
2280                        "count": app_events.len(),
2281                        "capacity": self.state.event_log.capacity(),
2282                        "events": app_events,
2283                    },
2284                });
2285                json_result(&result)
2286            }
2287            IntrospectAction::EventBusClear => {
2288                let tauri_cleared = self.state.event_bus.clear();
2289                self.state.event_log.clear();
2290                json_result(&serde_json::json!({
2291                    "tauri_events_cleared": tauri_cleared,
2292                    "app_events_cleared": true,
2293                }))
2294            }
2295        }
2296    }
2297
2298    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
2299
2300    #[tool(
2301        description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2302            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2303            CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2304            Actions:\n\
2305            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2306            - `list`: List all active fault injection rules.\n\
2307            - `clear`: Remove a specific fault rule (requires `command`).\n\
2308            - `clear_all`: Remove all fault rules.",
2309        annotations(
2310            read_only_hint = false,
2311            destructive_hint = true,
2312            idempotent_hint = false,
2313            open_world_hint = false
2314        )
2315    )]
2316    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2317        self.track_tool_call();
2318        if !self.state.privacy.is_tool_enabled("fault") {
2319            return tool_disabled("fault");
2320        }
2321
2322        match params.action {
2323            FaultAction::Inject => {
2324                let Some(command) = params.command else {
2325                    return missing_param("command", "inject");
2326                };
2327                let Some(fault_kind) = params.fault_type else {
2328                    return missing_param("fault_type", "inject");
2329                };
2330                let fault_type = match fault_kind {
2331                    FaultKind::Delay => {
2332                        let delay_ms = params.delay_ms.unwrap_or(1000);
2333                        crate::introspection::FaultType::Delay { delay_ms }
2334                    }
2335                    FaultKind::Error => {
2336                        let message = params
2337                            .error_message
2338                            .unwrap_or_else(|| "injected fault".to_string());
2339                        crate::introspection::FaultType::Error { message }
2340                    }
2341                    FaultKind::Drop => crate::introspection::FaultType::Drop,
2342                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2343                };
2344                let config = crate::introspection::FaultConfig {
2345                    command: command.clone(),
2346                    fault_type: fault_type.clone(),
2347                    trigger_count: 0,
2348                    max_triggers: params.max_triggers.unwrap_or(0),
2349                    created_at: std::time::Instant::now(),
2350                };
2351                self.state.fault_registry.inject(config);
2352                let result = serde_json::json!({
2353                    "injected": true,
2354                    "command": command,
2355                    "fault_type": fault_type,
2356                    "max_triggers": params.max_triggers.unwrap_or(0),
2357                });
2358                json_result(&result)
2359            }
2360            FaultAction::List => {
2361                let faults = self.state.fault_registry.list();
2362                let result = serde_json::json!({
2363                    "count": faults.len(),
2364                    "faults": faults.iter().map(|f| serde_json::json!({
2365                        "command": f.command,
2366                        "fault_type": f.fault_type,
2367                        "trigger_count": f.trigger_count,
2368                        "max_triggers": f.max_triggers,
2369                    })).collect::<Vec<_>>(),
2370                });
2371                json_result(&result)
2372            }
2373            FaultAction::Clear => {
2374                let Some(command) = params.command else {
2375                    return missing_param("command", "clear");
2376                };
2377                let removed = self.state.fault_registry.clear(&command);
2378                json_result(&serde_json::json!({
2379                    "removed": removed,
2380                    "command": command,
2381                }))
2382            }
2383            FaultAction::ClearAll => {
2384                let removed = self.state.fault_registry.clear_all();
2385                json_result(&serde_json::json!({
2386                    "removed": removed,
2387                }))
2388            }
2389        }
2390    }
2391
2392    // ── Cross-Layer Explanation ────────────────────────────────────────────
2393
2394    #[tool(
2395        description = "Correlate recent activity across all layers into a coherent narrative. \
2396            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2397            + window events across the Rust backend and webview simultaneously.\n\n\
2398            Actions:\n\
2399            - `summary`: High-level activity summary for the last N seconds (default 30). \
2400              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2401            - `last_action`: Correlate the most recent burst of events into a causal timeline \
2402              (e.g. 'IPC call → DOM update → console.log').\n\
2403            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2404        annotations(
2405            read_only_hint = true,
2406            destructive_hint = false,
2407            idempotent_hint = true,
2408            open_world_hint = false
2409        )
2410    )]
2411    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2412        self.track_tool_call();
2413        if !self.state.privacy.is_tool_enabled("explain") {
2414            return tool_disabled("explain");
2415        }
2416
2417        match params.action {
2418            ExplainAction::Summary => {
2419                let secs = params.seconds.unwrap_or(30);
2420                let since = chrono::Utc::now()
2421                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2422                let events = self.state.event_log.since(since);
2423
2424                let mut ipc_count = 0u64;
2425                let mut dom_mutations = 0u64;
2426                let mut state_changes = 0u64;
2427                let mut console_count = 0u64;
2428                let mut window_events = 0u64;
2429                let mut interactions = 0u64;
2430                let mut top_commands: HashMap<String, u64> = HashMap::new();
2431                let mut errors: Vec<String> = Vec::new();
2432
2433                for event in &events {
2434                    match event {
2435                        victauri_core::AppEvent::Ipc(call) => {
2436                            ipc_count += 1;
2437                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2438                            if let victauri_core::IpcResult::Err(e) = &call.result {
2439                                errors.push(format!("IPC {}: {e}", call.command));
2440                            }
2441                        }
2442                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2443                            dom_mutations += u64::from(*mutation_count)
2444                        }
2445                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2446                        victauri_core::AppEvent::Console { level, message, .. } => {
2447                            console_count += 1;
2448                            if level == "error" {
2449                                errors.push(format!("console.error: {message}"));
2450                            }
2451                        }
2452                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2453                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2454                        _ => {}
2455                    }
2456                }
2457
2458                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2459                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2460                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2461
2462                let narrative = format!(
2463                    "{ipc_count} IPC call{} in the last {secs}s{}. \
2464                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2465                     {console_count} console message{}, {window_events} window event{}. {}.",
2466                    if ipc_count == 1 { "" } else { "s" },
2467                    if top.is_empty() {
2468                        String::new()
2469                    } else {
2470                        format!(
2471                            ", dominated by {}",
2472                            top.iter()
2473                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2474                                .collect::<Vec<_>>()
2475                                .join(", ")
2476                        )
2477                    },
2478                    if dom_mutations == 1 { "" } else { "s" },
2479                    if interactions == 1 { "" } else { "s" },
2480                    if console_count == 1 { "" } else { "s" },
2481                    if window_events == 1 { "" } else { "s" },
2482                    if errors.is_empty() {
2483                        "No errors".to_string()
2484                    } else {
2485                        format!(
2486                            "{} error{}",
2487                            errors.len(),
2488                            if errors.len() == 1 { "" } else { "s" }
2489                        )
2490                    },
2491                );
2492
2493                let result = serde_json::json!({
2494                    "time_window_secs": secs,
2495                    "total_events": events.len(),
2496                    "ipc_calls": ipc_count,
2497                    "dom_mutations": dom_mutations,
2498                    "state_changes": state_changes,
2499                    "console_messages": console_count,
2500                    "window_events": window_events,
2501                    "interactions": interactions,
2502                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2503                        serde_json::json!({"command": cmd, "count": n})
2504                    }).collect::<Vec<_>>(),
2505                    "errors": errors,
2506                    "narrative": narrative,
2507                });
2508                json_result(&result)
2509            }
2510            ExplainAction::LastAction => {
2511                let secs = params.seconds.unwrap_or(5);
2512                let since = chrono::Utc::now()
2513                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2514                let events = self.state.event_log.since(since);
2515
2516                let timeline: Vec<serde_json::Value> = events
2517                    .iter()
2518                    .filter(|e| !e.is_internal())
2519                    .map(|event| match event {
2520                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
2521                            "time": call.timestamp.to_rfc3339_opts(
2522                                chrono::SecondsFormat::Millis, true
2523                            ),
2524                            "type": "ipc",
2525                            "detail": format!(
2526                                "{} {} ({}ms)",
2527                                call.command,
2528                                call.result,
2529                                call.duration_ms.unwrap_or(0)
2530                            ),
2531                        }),
2532                        victauri_core::AppEvent::DomMutation {
2533                            timestamp,
2534                            mutation_count,
2535                            webview_label,
2536                        } => serde_json::json!({
2537                            "time": timestamp.to_rfc3339_opts(
2538                                chrono::SecondsFormat::Millis, true
2539                            ),
2540                            "type": "dom_mutation",
2541                            "detail": format!(
2542                                "{mutation_count} element{} updated in {webview_label}",
2543                                if *mutation_count == 1 { "" } else { "s" }
2544                            ),
2545                        }),
2546                        victauri_core::AppEvent::DomInteraction {
2547                            timestamp,
2548                            action,
2549                            selector,
2550                            ..
2551                        } => serde_json::json!({
2552                            "time": timestamp.to_rfc3339_opts(
2553                                chrono::SecondsFormat::Millis, true
2554                            ),
2555                            "type": "interaction",
2556                            "detail": format!("{action} on {selector}"),
2557                        }),
2558                        victauri_core::AppEvent::StateChange {
2559                            timestamp,
2560                            key,
2561                            caused_by,
2562                        } => serde_json::json!({
2563                            "time": timestamp.to_rfc3339_opts(
2564                                chrono::SecondsFormat::Millis, true
2565                            ),
2566                            "type": "state_change",
2567                            "detail": format!(
2568                                "{key} changed{}",
2569                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2570                            ),
2571                        }),
2572                        victauri_core::AppEvent::Console {
2573                            timestamp,
2574                            level,
2575                            message,
2576                        } => serde_json::json!({
2577                            "time": timestamp.to_rfc3339_opts(
2578                                chrono::SecondsFormat::Millis, true
2579                            ),
2580                            "type": "console",
2581                            "detail": format!("console.{level}: {message}"),
2582                        }),
2583                        victauri_core::AppEvent::WindowEvent {
2584                            timestamp,
2585                            label,
2586                            event,
2587                        } => serde_json::json!({
2588                            "time": timestamp.to_rfc3339_opts(
2589                                chrono::SecondsFormat::Millis, true
2590                            ),
2591                            "type": "window_event",
2592                            "detail": format!("{event} on window '{label}'"),
2593                        }),
2594                        _ => serde_json::json!({
2595                            "time": event.timestamp().to_rfc3339_opts(
2596                                chrono::SecondsFormat::Millis, true
2597                            ),
2598                            "type": "other",
2599                            "detail": "unknown event type",
2600                        }),
2601                    })
2602                    .collect();
2603
2604                let narrative = if timeline.is_empty() {
2605                    format!("No activity in the last {secs}s.")
2606                } else {
2607                    let parts: Vec<String> = timeline
2608                        .iter()
2609                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2610                        .map(String::from)
2611                        .collect();
2612                    parts.join(" → ")
2613                };
2614
2615                let result = serde_json::json!({
2616                    "time_window_secs": secs,
2617                    "event_count": timeline.len(),
2618                    "timeline": timeline,
2619                    "narrative": narrative,
2620                });
2621                json_result(&result)
2622            }
2623            ExplainAction::Diff => {
2624                let secs = params.seconds.unwrap_or(10);
2625                let since = chrono::Utc::now()
2626                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2627                let events = self.state.event_log.since(since);
2628
2629                let mut ipc_commands: Vec<String> = Vec::new();
2630                let mut dom_changes = 0u64;
2631                let mut error_count = 0u64;
2632                let mut interaction_count = 0u64;
2633                let mut console_messages = 0u64;
2634
2635                for event in &events {
2636                    if event.is_internal() {
2637                        continue;
2638                    }
2639                    match event {
2640                        victauri_core::AppEvent::Ipc(call) => {
2641                            ipc_commands.push(call.command.clone());
2642                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2643                                error_count += 1;
2644                            }
2645                        }
2646                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2647                            dom_changes += u64::from(*mutation_count)
2648                        }
2649                        victauri_core::AppEvent::DomInteraction { .. } => {
2650                            interaction_count += 1;
2651                        }
2652                        victauri_core::AppEvent::Console { level, .. } => {
2653                            console_messages += 1;
2654                            if level == "error" {
2655                                error_count += 1;
2656                            }
2657                        }
2658                        _ => {}
2659                    }
2660                }
2661
2662                ipc_commands.dedup();
2663
2664                let result = serde_json::json!({
2665                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2666                    "time_window_secs": secs,
2667                    "total_events": events.len(),
2668                    "ipc_calls_made": ipc_commands.len(),
2669                    "unique_commands": ipc_commands,
2670                    "dom_elements_changed": dom_changes,
2671                    "interactions": interaction_count,
2672                    "console_messages": console_messages,
2673                    "errors": error_count,
2674                });
2675                json_result(&result)
2676            }
2677        }
2678    }
2679}
2680
2681impl VictauriMcpHandler {
2682    /// Create a new handler backed by the given state and webview bridge.
2683    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2684        Self {
2685            state,
2686            bridge,
2687            subscriptions: Arc::new(Mutex::new(HashSet::new())),
2688            bridge_checked: Arc::new(AtomicBool::new(false)),
2689            probed_labels: Arc::new(Mutex::new(HashSet::new())),
2690        }
2691    }
2692
2693    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2694        self.state.privacy.is_tool_enabled(name)
2695    }
2696
2697    pub(crate) async fn execute_tool(
2698        &self,
2699        name: &str,
2700        args: serde_json::Value,
2701    ) -> Result<CallToolResult, rest::ToolCallError> {
2702        if !self.state.privacy.is_tool_enabled(name) {
2703            return Ok(tool_disabled(name));
2704        }
2705        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2706        let start = std::time::Instant::now();
2707        tracing::debug!(tool = %name, "REST tool invocation started");
2708
2709        let result = match name {
2710            "eval_js" => {
2711                let p: EvalJsParams = Self::parse_args(args)?;
2712                self.eval_js(Parameters(p)).await
2713            }
2714            "dom_snapshot" => {
2715                let p: SnapshotParams = Self::parse_args(args)?;
2716                self.dom_snapshot(Parameters(p)).await
2717            }
2718            "find_elements" => {
2719                let p: FindElementsParams = Self::parse_args(args)?;
2720                self.find_elements(Parameters(p)).await
2721            }
2722            "invoke_command" => {
2723                let p: InvokeCommandParams = Self::parse_args(args)?;
2724                self.invoke_command(Parameters(p)).await
2725            }
2726            "screenshot" => {
2727                let p: ScreenshotParams = Self::parse_args(args)?;
2728                self.screenshot(Parameters(p)).await
2729            }
2730            "verify_state" => {
2731                let p: VerifyStateParams = Self::parse_args(args)?;
2732                self.verify_state(Parameters(p)).await
2733            }
2734            "detect_ghost_commands" => {
2735                let p: GhostCommandParams = Self::parse_args(args)?;
2736                self.detect_ghost_commands(Parameters(p)).await
2737            }
2738            "check_ipc_integrity" => {
2739                let p: IpcIntegrityParams = Self::parse_args(args)?;
2740                self.check_ipc_integrity(Parameters(p)).await
2741            }
2742            "wait_for" => {
2743                let p: WaitForParams = Self::parse_args(args)?;
2744                self.wait_for(Parameters(p)).await
2745            }
2746            "assert_semantic" => {
2747                let p: SemanticAssertParams = Self::parse_args(args)?;
2748                self.assert_semantic(Parameters(p)).await
2749            }
2750            "resolve_command" => {
2751                let p: ResolveCommandParams = Self::parse_args(args)?;
2752                self.resolve_command(Parameters(p)).await
2753            }
2754            "get_registry" => {
2755                let p: RegistryParams = Self::parse_args(args)?;
2756                self.get_registry(Parameters(p)).await
2757            }
2758            "get_memory_stats" => self.get_memory_stats().await,
2759            "get_plugin_info" => self.get_plugin_info().await,
2760            "get_diagnostics" => {
2761                let p: DiagnosticsParams = Self::parse_args(args)?;
2762                self.get_diagnostics(Parameters(p)).await
2763            }
2764            "app_info" => self.app_info().await,
2765            "list_app_dir" => {
2766                let p: ListAppDirParams = Self::parse_args(args)?;
2767                self.list_app_dir(Parameters(p)).await
2768            }
2769            "read_app_file" => {
2770                let p: ReadAppFileParams = Self::parse_args(args)?;
2771                self.read_app_file(Parameters(p)).await
2772            }
2773            #[cfg(feature = "sqlite")]
2774            "query_db" => {
2775                let p: QueryDbParams = Self::parse_args(args)?;
2776                self.query_db(Parameters(p)).await
2777            }
2778            "interact" => {
2779                let p: InteractParams = Self::parse_args(args)?;
2780                self.interact(Parameters(p)).await
2781            }
2782            "input" => {
2783                let p: InputParams = Self::parse_args(args)?;
2784                self.input(Parameters(p)).await
2785            }
2786            "window" => {
2787                let p: WindowParams = Self::parse_args(args)?;
2788                self.window(Parameters(p)).await
2789            }
2790            "storage" => {
2791                let p: StorageParams = Self::parse_args(args)?;
2792                self.storage(Parameters(p)).await
2793            }
2794            "navigate" => {
2795                let p: NavigateParams = Self::parse_args(args)?;
2796                self.navigate(Parameters(p)).await
2797            }
2798            "recording" => {
2799                let p: RecordingParams = Self::parse_args(args)?;
2800                self.recording(Parameters(p)).await
2801            }
2802            "inspect" => {
2803                let p: InspectParams = Self::parse_args(args)?;
2804                self.inspect(Parameters(p)).await
2805            }
2806            "css" => {
2807                let p: CssParams = Self::parse_args(args)?;
2808                self.css(Parameters(p)).await
2809            }
2810            "logs" => {
2811                let p: LogsParams = Self::parse_args(args)?;
2812                self.logs(Parameters(p)).await
2813            }
2814            "introspect" => {
2815                let p: IntrospectParams = Self::parse_args(args)?;
2816                self.introspect(Parameters(p)).await
2817            }
2818            "fault" => {
2819                let p: FaultParams = Self::parse_args(args)?;
2820                self.fault(Parameters(p)).await
2821            }
2822            "explain" => {
2823                let p: ExplainParams = Self::parse_args(args)?;
2824                self.explain(Parameters(p)).await
2825            }
2826            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
2827        };
2828
2829        let elapsed = start.elapsed();
2830        tracing::debug!(
2831            tool = %name,
2832            elapsed_ms = elapsed.as_millis() as u64,
2833            "REST tool invocation completed"
2834        );
2835
2836        if self.state.privacy.redaction_enabled {
2837            Ok(Self::redact_result(result, &self.state.privacy))
2838        } else {
2839            Ok(result)
2840        }
2841    }
2842
2843    fn parse_args<T: serde::de::DeserializeOwned>(
2844        args: serde_json::Value,
2845    ) -> Result<T, rest::ToolCallError> {
2846        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
2847    }
2848
2849    fn redact_result(
2850        mut result: CallToolResult,
2851        privacy: &crate::privacy::PrivacyConfig,
2852    ) -> CallToolResult {
2853        for item in &mut result.content {
2854            if let RawContent::Text(ref mut tc) = item.raw {
2855                tc.text = privacy.redact_output(&tc.text);
2856            }
2857        }
2858        result
2859    }
2860
2861    fn track_tool_call(&self) {
2862        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2863    }
2864
2865    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
2866        match dir.unwrap_or(AppDir::Data) {
2867            AppDir::Data => self.bridge.app_data_dir(),
2868            AppDir::Config => self.bridge.app_config_dir(),
2869            AppDir::Log => self.bridge.app_log_dir(),
2870            AppDir::LocalData => self.bridge.app_local_data_dir(),
2871        }
2872    }
2873
2874    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
2875        let canon_base = std::fs::canonicalize(base)
2876            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
2877        let canon_target = std::fs::canonicalize(target)
2878            .map_err(|e| format!("cannot resolve target path: {e}"))?;
2879        if !canon_target.starts_with(&canon_base) {
2880            return Err("path traversal not allowed".to_string());
2881        }
2882        Ok(())
2883    }
2884
2885    fn list_dir_recursive(
2886        dir: &std::path::Path,
2887        base: &std::path::Path,
2888        depth: u32,
2889        max_depth: u32,
2890        pattern: Option<&str>,
2891        entries: &mut Vec<serde_json::Value>,
2892    ) {
2893        let Ok(read_dir) = std::fs::read_dir(dir) else {
2894            return;
2895        };
2896        for entry in read_dir.flatten() {
2897            let path = entry.path();
2898            if path.is_symlink() {
2899                continue;
2900            }
2901            let name = entry.file_name().to_string_lossy().into_owned();
2902            let relative = path
2903                .strip_prefix(base)
2904                .unwrap_or(&path)
2905                .to_string_lossy()
2906                .into_owned();
2907
2908            if let Some(pat) = pattern
2909                && !Self::matches_glob(&name, pat)
2910                && !path.is_dir()
2911            {
2912                continue;
2913            }
2914
2915            let is_dir = path.is_dir();
2916            let meta = std::fs::metadata(&path).ok();
2917
2918            entries.push(serde_json::json!({
2919                "name": name,
2920                "path": relative,
2921                "is_dir": is_dir,
2922                "size": meta.as_ref().map(std::fs::Metadata::len),
2923                "modified": meta.as_ref()
2924                    .and_then(|m| m.modified().ok())
2925                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
2926                        .unwrap_or_default().as_secs()),
2927            }));
2928
2929            if is_dir && depth < max_depth {
2930                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
2931            }
2932        }
2933    }
2934
2935    fn matches_glob(name: &str, pattern: &str) -> bool {
2936        if pattern == "*" {
2937            return true;
2938        }
2939        if let Some(suffix) = pattern.strip_prefix("*.") {
2940            return name.ends_with(&format!(".{suffix}"));
2941        }
2942        if let Some(prefix) = pattern.strip_suffix("*") {
2943            return name.starts_with(prefix);
2944        }
2945        name == pattern
2946    }
2947
2948    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
2949        match self.eval_with_return(code, webview_label).await {
2950            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2951            Err(e) => tool_error(e),
2952        }
2953    }
2954
2955    async fn eval_with_return(
2956        &self,
2957        code: &str,
2958        webview_label: Option<&str>,
2959    ) -> Result<String, String> {
2960        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
2961            .await
2962    }
2963
2964    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
2965        let id = uuid::Uuid::new_v4().to_string();
2966        let (tx, rx) = tokio::sync::oneshot::channel();
2967        {
2968            let mut pending = self.state.pending_evals.lock().await;
2969            pending.insert(id.clone(), tx);
2970        }
2971        let id_js = js_string(&id);
2972        let probe = format!(
2973            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
2974        );
2975        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
2976            self.state.pending_evals.lock().await.remove(&id);
2977            return Err(format!("eval injection failed: {e}"));
2978        }
2979        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
2980            Ok(())
2981        } else {
2982            self.state.pending_evals.lock().await.remove(&id);
2983            let label = webview_label.unwrap_or("default");
2984            Err(format!(
2985                "bridge not responding on window '{label}' — the window may be hidden, \
2986                 missing the victauri capability, or the JS bridge is not loaded"
2987            ))
2988        }
2989    }
2990
2991    async fn eval_with_return_timeout(
2992        &self,
2993        code: &str,
2994        webview_label: Option<&str>,
2995        timeout: std::time::Duration,
2996    ) -> Result<String, String> {
2997        self.track_tool_call();
2998
2999        // Wait for the JS bridge ready signal (sent on bridge init) before
3000        // attempting evals.  For explicitly targeted windows the probe
3001        // mechanism is still used because the ready signal only proves that
3002        // *some* webview's bridge loaded — not necessarily the targeted one.
3003        if !self
3004            .state
3005            .bridge_ready
3006            .load(std::sync::atomic::Ordering::Acquire)
3007        {
3008            let notified = self.state.bridge_notify.notified();
3009            if !self
3010                .state
3011                .bridge_ready
3012                .load(std::sync::atomic::Ordering::Acquire)
3013            {
3014                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3015            }
3016        }
3017
3018        if webview_label.is_some() {
3019            let label_key = webview_label.unwrap_or_default().to_string();
3020            let already_probed = self.probed_labels.lock().await.contains(&label_key);
3021            if !already_probed {
3022                self.probe_bridge(webview_label).await?;
3023                self.probed_labels.lock().await.insert(label_key);
3024            }
3025        }
3026
3027        let id = uuid::Uuid::new_v4().to_string();
3028        let (tx, rx) = tokio::sync::oneshot::channel();
3029
3030        {
3031            let mut pending = self.state.pending_evals.lock().await;
3032            if pending.len() >= MAX_PENDING_EVALS {
3033                return Err(format!(
3034                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3035                ));
3036            }
3037            pending.insert(id.clone(), tx);
3038        }
3039
3040        // Auto-prepend `return` so bare expressions produce a value — but ONLY
3041        // for single expressions. Multi-statement blocks (or code containing an
3042        // explicit `return`) are used as-is. Prepending `return` to a statement
3043        // block like `foo(); return bar()` would parse as `return foo();` and
3044        // silently discard everything after the first statement (issue: core
3045        // primitive returned wrong/undefined values for "do X, then return Y").
3046        let code = if should_prepend_return(code) {
3047            format!("return {}", code.trim())
3048        } else {
3049            code.trim().to_string()
3050        };
3051
3052        let id_js = js_string(&id);
3053        let inject = format!(
3054            r"
3055            (async () => {{
3056                try {{
3057                    const __result = await (async () => {{ {code} }})();
3058                    const __type = __result === undefined ? 'undefined'
3059                        : __result === null ? 'null' : 'value';
3060                    const __val = __type === 'undefined' ? null
3061                        : __type === 'null' ? null : __result;
3062                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3063                        id: {id_js},
3064                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3065                    }});
3066                }} catch (e) {{
3067                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3068                        id: {id_js},
3069                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3070                    }});
3071                }}
3072            }})();
3073            "
3074        );
3075
3076        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3077            self.state.pending_evals.lock().await.remove(&id);
3078            return Err(format!("eval injection failed: {e}"));
3079        }
3080
3081        match tokio::time::timeout(timeout, rx).await {
3082            Ok(Ok(raw)) => {
3083                self.check_bridge_version_once();
3084                if raw.len() > MAX_EVAL_RESULT_LEN {
3085                    return Err(format!(
3086                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3087                        raw.len()
3088                    ));
3089                }
3090                unwrap_eval_envelope(raw)
3091            }
3092            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3093            Err(_) => {
3094                self.state.pending_evals.lock().await.remove(&id);
3095                Err(format!(
3096                    "eval timed out after {}s — the code never resolved. Common causes: a \
3097                     JavaScript syntax error in the injected code (parse errors cannot be \
3098                     reported by the webview and surface only as this timeout), an unresolved \
3099                     promise, or an infinite loop. Verify the code parses and resolves.",
3100                    timeout.as_secs()
3101                ))
3102            }
3103        }
3104    }
3105
3106    #[cfg(feature = "sqlite")]
3107    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3108        // Roots: configured db_search_paths first, then app directories.
3109        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3110        for d in [
3111            self.bridge.app_data_dir(),
3112            self.bridge.app_local_data_dir(),
3113            self.bridge.app_config_dir(),
3114        ]
3115        .into_iter()
3116        .flatten()
3117        {
3118            roots.push(d);
3119        }
3120
3121        let path = if let Some(p) = db_path {
3122            let candidate = std::path::Path::new(p);
3123            if candidate.is_absolute() {
3124                if !roots
3125                    .iter()
3126                    .any(|r| Self::safe_within(r, candidate).is_ok())
3127                {
3128                    return Err(format!(
3129                        "absolute path '{p}' is not within an allowed directory; \
3130                         register its parent via VictauriBuilder::db_search_paths"
3131                    ));
3132                }
3133                candidate.to_path_buf()
3134            } else {
3135                roots
3136                    .iter()
3137                    .map(|r| r.join(p))
3138                    .find(|c| c.exists())
3139                    .ok_or_else(|| format!("database not found: {p}"))?
3140            }
3141        } else {
3142            roots
3143                .iter()
3144                .flat_map(|r| crate::database::discover_databases(r))
3145                .next()
3146                .ok_or_else(|| {
3147                    "no database found in app directories or configured db_search_paths".to_string()
3148                })?
3149        };
3150        // No further containment check needed: the path is either discovered
3151        // within an allowed root, an existing relative file joined onto an
3152        // allowed root, or an absolute path already verified above. (A
3153        // safe_within check against app_data_dir would fail when that directory
3154        // does not exist — common for apps that store data elsewhere.)
3155        let path_str = path
3156            .to_str()
3157            .ok_or_else(|| "invalid path encoding".to_string())?
3158            .to_string();
3159
3160        tokio::task::spawn_blocking(move || {
3161            let conn = rusqlite::Connection::open_with_flags(
3162                &path_str,
3163                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3164            )
3165            .map_err(|e| format!("cannot open database: {e}"))?;
3166
3167            let journal_mode: String = conn
3168                .pragma_query_value(None, "journal_mode", |r| r.get(0))
3169                .unwrap_or_else(|_| "unknown".to_string());
3170
3171            let page_count: i64 = conn
3172                .pragma_query_value(None, "page_count", |r| r.get(0))
3173                .unwrap_or(0);
3174
3175            let page_size: i64 = conn
3176                .pragma_query_value(None, "page_size", |r| r.get(0))
3177                .unwrap_or(0);
3178
3179            let freelist_count: i64 = conn
3180                .pragma_query_value(None, "freelist_count", |r| r.get(0))
3181                .unwrap_or(0);
3182
3183            let wal_checkpoint: String = if journal_mode == "wal" {
3184                let mut info = String::from("n/a");
3185                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3186                    let busy: i64 = r.get(0)?;
3187                    let checkpointed: i64 = r.get(1)?;
3188                    let total: i64 = r.get(2)?;
3189                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3190                    Ok(())
3191                });
3192                info
3193            } else {
3194                "n/a (not WAL mode)".to_string()
3195            };
3196
3197            let integrity: String = conn
3198                .pragma_query_value(None, "quick_check", |r| r.get(0))
3199                .unwrap_or_else(|_| "failed".to_string());
3200
3201            let db_size_bytes = page_count * page_size;
3202            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3203
3204            let mut tables = Vec::new();
3205            if let Ok(mut stmt) =
3206                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3207                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3208            {
3209                for name in rows.flatten() {
3210                    let count: i64 = conn
3211                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3212                        .unwrap_or(0);
3213                    tables.push(serde_json::json!({
3214                        "name": name,
3215                        "row_count": count,
3216                    }));
3217                }
3218            }
3219
3220            Ok(serde_json::json!({
3221                "database": path_str,
3222                "journal_mode": journal_mode,
3223                "page_count": page_count,
3224                "page_size": page_size,
3225                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3226                "freelist_count": freelist_count,
3227                "wal_checkpoint": wal_checkpoint,
3228                "integrity_check": integrity,
3229                "tables": tables,
3230            }))
3231        })
3232        .await
3233        .map_err(|e| format!("db health task failed: {e}"))?
3234    }
3235
3236    fn check_bridge_version_once(&self) {
3237        if self.bridge_checked.swap(true, Ordering::Relaxed) {
3238            return;
3239        }
3240        let handler = self.clone();
3241        tokio::spawn(async move {
3242            match handler
3243                .eval_with_return_timeout(
3244                    "window.__VICTAURI__?.version",
3245                    None,
3246                    std::time::Duration::from_secs(5),
3247                )
3248                .await
3249            {
3250                Ok(v) => {
3251                    let v = v.trim_matches('"');
3252                    if v == BRIDGE_VERSION {
3253                        tracing::debug!("Bridge version verified: {v}");
3254                    } else {
3255                        tracing::warn!(
3256                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3257                        );
3258                    }
3259                }
3260                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3261            }
3262        });
3263    }
3264}
3265
3266const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3267It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3268(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3269(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3270\n\nBACKEND tools (direct Rust access, no webview needed): \
3271'app_info' (app config, directory paths, discovered databases, process info), \
3272'list_app_dir' (browse app data/config/log directories), \
3273'read_app_file' (read files from app directories), \
3274'query_db' (read-only SQLite queries with auto-discovery). \
3275\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3276'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3277capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3278Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3279capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3280task tracking, and automatic Tauri event bus monitoring. \
3281'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3282drops, and response corruption into Tauri commands at the Rust layer. \
3283'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3284activity across IPC + DOM + console + network + window events into a coherent narrative. \
3285\n\nWEBVIEW tools: \
3286'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3287'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3288'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3289\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3290\n\nCOMPOUND tools with an 'action' parameter: \
3291'window' (get_state, list, manage, resize, move_to, set_title), \
3292'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3293set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3294get_events, events_between, get_replay, export, import, replay), \
3295'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3296\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3297get_memory_stats, get_plugin_info, get_diagnostics.";
3298
3299impl ServerHandler for VictauriMcpHandler {
3300    fn get_info(&self) -> ServerInfo {
3301        ServerInfo::new(
3302            ServerCapabilities::builder()
3303                .enable_tools()
3304                .enable_resources()
3305                .enable_resources_subscribe()
3306                .build(),
3307        )
3308        .with_instructions(SERVER_INSTRUCTIONS)
3309    }
3310
3311    async fn list_tools(
3312        &self,
3313        _request: Option<PaginatedRequestParams>,
3314        _context: RequestContext<RoleServer>,
3315    ) -> Result<ListToolsResult, ErrorData> {
3316        let all_tools = Self::tool_router().list_all();
3317        let filtered: Vec<Tool> = all_tools
3318            .into_iter()
3319            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3320            .collect();
3321        Ok(ListToolsResult {
3322            tools: filtered,
3323            ..Default::default()
3324        })
3325    }
3326
3327    async fn call_tool(
3328        &self,
3329        request: CallToolRequestParams,
3330        context: RequestContext<RoleServer>,
3331    ) -> Result<CallToolResult, ErrorData> {
3332        let tool_name: String = request.name.as_ref().to_owned();
3333        if !self.state.privacy.is_tool_enabled(&tool_name) {
3334            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3335            return Ok(tool_disabled(&tool_name));
3336        }
3337        self.state
3338            .tool_invocations
3339            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3340        let start = std::time::Instant::now();
3341        tracing::debug!(tool = %tool_name, "tool invocation started");
3342        let ctx = ToolCallContext::new(self, request, context);
3343        let result = Self::tool_router().call(ctx).await;
3344        let elapsed = start.elapsed();
3345        tracing::debug!(
3346            tool = %tool_name,
3347            elapsed_ms = elapsed.as_millis() as u64,
3348            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3349            "tool invocation completed"
3350        );
3351
3352        // Centralized output redaction: apply to all text content so no
3353        // individual tool can accidentally leak secrets.
3354        if self.state.privacy.redaction_enabled {
3355            result.map(|mut r| {
3356                for item in &mut r.content {
3357                    if let RawContent::Text(ref mut tc) = item.raw {
3358                        tc.text = self.state.privacy.redact_output(&tc.text);
3359                    }
3360                }
3361                r
3362            })
3363        } else {
3364            result
3365        }
3366    }
3367
3368    fn get_tool(&self, name: &str) -> Option<Tool> {
3369        if !self.state.privacy.is_tool_enabled(name) {
3370            return None;
3371        }
3372        Self::tool_router().get(name).cloned()
3373    }
3374
3375    async fn list_resources(
3376        &self,
3377        _request: Option<PaginatedRequestParams>,
3378        _context: RequestContext<RoleServer>,
3379    ) -> Result<ListResourcesResult, ErrorData> {
3380        Ok(ListResourcesResult {
3381            resources: vec![
3382                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3383                    .with_description(
3384                        "Live IPC call log — all commands invoked between frontend and backend",
3385                    )
3386                    .with_mime_type("application/json")
3387                    .no_annotation(),
3388                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3389                    .with_description(
3390                        "Current state of all Tauri windows — position, size, visibility, focus",
3391                    )
3392                    .with_mime_type("application/json")
3393                    .no_annotation(),
3394                RawResource::new(RESOURCE_URI_STATE, "state")
3395                    .with_description(
3396                        "Victauri plugin state — event count, registered commands, memory stats",
3397                    )
3398                    .with_mime_type("application/json")
3399                    .no_annotation(),
3400            ],
3401            ..Default::default()
3402        })
3403    }
3404
3405    async fn read_resource(
3406        &self,
3407        request: ReadResourceRequestParams,
3408        _context: RequestContext<RoleServer>,
3409    ) -> Result<ReadResourceResult, ErrorData> {
3410        let uri = &request.uri;
3411        let json = match uri.as_str() {
3412            RESOURCE_URI_IPC_LOG => {
3413                if let Ok(json) = self
3414                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3415                    .await
3416                {
3417                    json
3418                } else {
3419                    let calls = self.state.event_log.ipc_calls();
3420                    serde_json::to_string_pretty(&calls)
3421                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3422                }
3423            }
3424            RESOURCE_URI_WINDOWS => {
3425                let states = self.bridge.get_window_states(None);
3426                serde_json::to_string_pretty(&states)
3427                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3428            }
3429            RESOURCE_URI_STATE => {
3430                let state_json = serde_json::json!({
3431                    "events_captured": self.state.event_log.len(),
3432                    "commands_registered": self.state.registry.count(),
3433                    "memory": crate::memory::current_stats(),
3434                    "port": self.state.port.load(Ordering::Relaxed),
3435                });
3436                serde_json::to_string_pretty(&state_json)
3437                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3438            }
3439            _ => {
3440                return Err(ErrorData::resource_not_found(
3441                    format!("unknown resource: {uri}"),
3442                    None,
3443                ));
3444            }
3445        };
3446
3447        let json = if self.state.privacy.redaction_enabled {
3448            self.state.privacy.redact_output(&json)
3449        } else {
3450            json
3451        };
3452
3453        Ok(ReadResourceResult::new(vec![ResourceContents::text(
3454            json, uri,
3455        )]))
3456    }
3457
3458    async fn subscribe(
3459        &self,
3460        request: SubscribeRequestParams,
3461        _context: RequestContext<RoleServer>,
3462    ) -> Result<(), ErrorData> {
3463        let uri = &request.uri;
3464        match uri.as_str() {
3465            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3466                self.subscriptions.lock().await.insert(uri.clone());
3467                tracing::info!("Client subscribed to resource: {uri}");
3468                Ok(())
3469            }
3470            _ => Err(ErrorData::resource_not_found(
3471                format!("unknown resource: {uri}"),
3472                None,
3473            )),
3474        }
3475    }
3476
3477    async fn unsubscribe(
3478        &self,
3479        request: UnsubscribeRequestParams,
3480        _context: RequestContext<RoleServer>,
3481    ) -> Result<(), ErrorData> {
3482        self.subscriptions.lock().await.remove(&request.uri);
3483        tracing::info!("Client unsubscribed from resource: {}", request.uri);
3484        Ok(())
3485    }
3486}
3487
3488/// Build a JS expression that takes an array of log entries (`source_expr`),
3489/// keeps at most `limit` of the most recent, and truncates any per-entry field
3490/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
3491/// the eval size cap on busy apps where individual entries carry large bodies.
3492///
3493/// The returned code is a complete `return (...)` statement.
3494fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
3495    let mb = MAX_LOG_FIELD_BYTES;
3496    format!(
3497        r"return (function() {{
3498            var MB = {mb};
3499            function trimField(v) {{
3500                if (typeof v === 'string') {{
3501                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
3502                }}
3503                if (v && typeof v === 'object') {{
3504                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
3505                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
3506                }}
3507                return v;
3508            }}
3509            function trimEntry(e) {{
3510                if (e == null || typeof e !== 'object') return e;
3511                var out = Array.isArray(e) ? [] : {{}};
3512                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
3513                return out;
3514            }}
3515            var arr = {source_expr} || [];
3516            if (arr.length > {limit}) arr = arr.slice(-{limit});
3517            return arr.map(trimEntry);
3518        }})()"
3519    )
3520}
3521
3522/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
3523/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
3524/// value/error string returned to callers.
3525///
3526/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
3527/// disabled — an unbounded recursive parse of a pathologically deep result
3528/// overflows the worker thread stack and crashes the host). When the parse
3529/// fails because the value is too deeply nested, the envelope is stripped by
3530/// string slicing (no recursion) so the actual value is still returned rather
3531/// than leaking the raw envelope string.
3532fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
3533    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
3534        if let Some(err) = envelope.get("__victauri_err") {
3535            return Err(format!(
3536                "JavaScript error: {}",
3537                err.as_str().unwrap_or("unknown error")
3538            ));
3539        }
3540        if envelope.get("__victauri_ok").is_some() {
3541            let js_type = envelope
3542                .get("__victauri_type")
3543                .and_then(|t| t.as_str())
3544                .unwrap_or("value");
3545            return match js_type {
3546                "undefined" => Ok("undefined".to_string()),
3547                "null" => Ok("null".to_string()),
3548                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
3549                    .unwrap_or_else(|_| "null".to_string())),
3550            };
3551        }
3552    }
3553    // Fallback for results too deeply nested for the recursion-limited parser.
3554    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
3555        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
3556    {
3557        return Ok(after[..idx].to_string());
3558    }
3559    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
3560        let msg = after.trim_end_matches('}').trim_matches('"');
3561        return Err(format!("JavaScript error: {msg}"));
3562    }
3563    Ok(raw)
3564}
3565
3566/// Statement keywords where a leading `return` would be a syntax error.
3567const STMT_STARTS: &[&str] = &[
3568    "return ",
3569    "return;",
3570    "return\n",
3571    "return\t",
3572    "if ",
3573    "if(",
3574    "for ",
3575    "for(",
3576    "while ",
3577    "while(",
3578    "switch ",
3579    "switch(",
3580    "try ",
3581    "try{",
3582    "const ",
3583    "let ",
3584    "var ",
3585    "function ",
3586    "function(",
3587    "function*",
3588    "class ",
3589    "throw ",
3590    "do ",
3591    "do{",
3592    "{",
3593    "async function",
3594    "debugger",
3595];
3596
3597/// String/template/comment scan state for [`should_prepend_return`].
3598#[derive(PartialEq, Clone, Copy)]
3599enum ScanState {
3600    Code,
3601    SingleQuote,
3602    DoubleQuote,
3603    Template,
3604}
3605
3606/// Decide whether to wrap `code` with a leading `return`.
3607///
3608/// Only a single bare expression should get `return` prepended. Code that is a
3609/// multi-statement block, contains an explicit top-level `return`, or starts
3610/// with a statement keyword is used as-is — prepending `return` to such code
3611/// would execute only the first statement and silently discard the rest.
3612///
3613/// The scan is string/template/comment-aware and only treats a `;` or an
3614/// explicit `return` token as significant when it occurs at bracket depth 0
3615/// outside of any string, template literal, or comment.
3616fn should_prepend_return(code: &str) -> bool {
3617    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
3618
3619    let code = code.trim();
3620    if code.is_empty() {
3621        return false;
3622    }
3623
3624    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
3625        return false;
3626    }
3627
3628    let bytes = code.as_bytes();
3629    let mut i = 0;
3630    let mut depth: i32 = 0;
3631    let mut state = ScanState::Code;
3632
3633    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
3634    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
3635    let is_return_token = |i: usize| -> bool {
3636        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
3637        prev_ok
3638            && code[i..].starts_with("return")
3639            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
3640    };
3641
3642    while i < bytes.len() {
3643        let c = bytes[i];
3644        match state {
3645            Code => match c {
3646                b'\'' => state = SingleQuote,
3647                b'"' => state = DoubleQuote,
3648                b'`' => state = Template,
3649                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
3650                    while i < bytes.len() && bytes[i] != b'\n' {
3651                        i += 1;
3652                    }
3653                    continue;
3654                }
3655                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
3656                    i += 2;
3657                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
3658                        i += 1;
3659                    }
3660                    i += 2;
3661                    continue;
3662                }
3663                b'(' | b'[' | b'{' => depth += 1,
3664                b')' | b']' | b'}' => depth -= 1,
3665                // A top-level `;` with more code after it == multi-statement.
3666                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
3667                // An explicit top-level `return` token means the code already returns.
3668                b'r' if depth <= 0 && is_return_token(i) => return false,
3669                _ => {}
3670            },
3671            SingleQuote => {
3672                if c == b'\\' {
3673                    i += 1;
3674                } else if c == b'\'' {
3675                    state = Code;
3676                }
3677            }
3678            DoubleQuote => {
3679                if c == b'\\' {
3680                    i += 1;
3681                } else if c == b'"' {
3682                    state = Code;
3683                }
3684            }
3685            Template => {
3686                if c == b'\\' {
3687                    i += 1;
3688                } else if c == b'`' {
3689                    state = Code;
3690                }
3691            }
3692        }
3693        i += 1;
3694    }
3695
3696    true
3697}
3698
3699#[cfg(test)]
3700mod tests {
3701    use super::*;
3702
3703    #[test]
3704    fn prepend_return_bare_expressions() {
3705        assert!(should_prepend_return("document.title"));
3706        assert!(should_prepend_return("5 + 5"));
3707        assert!(should_prepend_return("\"justexpr\""));
3708        assert!(should_prepend_return("await fetch('/x')"));
3709        assert!(should_prepend_return(
3710            "document.querySelectorAll('a').length"
3711        ));
3712        assert!(should_prepend_return("x ? a : b"));
3713        // Single trailing semicolon on a bare expression is still an expression.
3714        assert!(should_prepend_return("document.title;"));
3715        // Semicolons inside strings must not be treated as boundaries.
3716        assert!(should_prepend_return("'a;b;c'"));
3717        assert!(should_prepend_return("\"x;y\".length"));
3718        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
3719        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
3720    }
3721
3722    #[test]
3723    fn no_prepend_for_statement_blocks() {
3724        // The original silent-corruption cases.
3725        assert!(!should_prepend_return(
3726            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
3727        ));
3728        assert!(!should_prepend_return(
3729            "window.scrollTo(0,50); return window.scrollY"
3730        ));
3731        assert!(!should_prepend_return("console.log('x'); return 123"));
3732        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
3733        // Explicit return without a preceding semicolon (newline-separated).
3734        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
3735    }
3736
3737    #[test]
3738    fn no_prepend_for_statement_keywords() {
3739        assert!(!should_prepend_return("return 42"));
3740        assert!(!should_prepend_return("const x = 1; return x"));
3741        assert!(!should_prepend_return("let y = 2"));
3742        assert!(!should_prepend_return("var z = 3"));
3743        assert!(!should_prepend_return("if (x) { return 1 }"));
3744        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
3745        assert!(!should_prepend_return("throw new Error('x')"));
3746        assert!(!should_prepend_return("function f(){}"));
3747        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
3748    }
3749
3750    #[test]
3751    fn empty_code_no_prepend() {
3752        assert!(!should_prepend_return(""));
3753        assert!(!should_prepend_return("   "));
3754    }
3755
3756    #[test]
3757    fn envelope_unwrap_value() {
3758        assert_eq!(
3759            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
3760            Ok("\"4DA\"".to_string())
3761        );
3762        assert_eq!(
3763            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
3764            Ok("42".to_string())
3765        );
3766    }
3767
3768    #[test]
3769    fn envelope_unwrap_undefined_null() {
3770        assert_eq!(
3771            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
3772            Ok("undefined".to_string())
3773        );
3774        assert_eq!(
3775            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
3776            Ok("null".to_string())
3777        );
3778    }
3779
3780    #[test]
3781    fn envelope_unwrap_error() {
3782        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
3783        assert!(r.unwrap_err().contains("boom"));
3784    }
3785
3786    #[test]
3787    fn envelope_unwrap_deeply_nested_does_not_leak() {
3788        // Build an envelope whose value is nested far deeper than serde_json's
3789        // default recursion limit (128). The full parse fails, so the slice
3790        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
3791        let mut value = String::from("0");
3792        for _ in 0..300 {
3793            value = format!("{{\"n\":{value}}}");
3794        }
3795        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
3796        let out = unwrap_eval_envelope(raw).unwrap();
3797        assert!(
3798            out.starts_with(r#"{"n":"#),
3799            "deep value should be unwrapped, got: {}",
3800            &out[..out.len().min(40)]
3801        );
3802        assert!(
3803            !out.contains("__victauri_ok"),
3804            "envelope must not leak into the result"
3805        );
3806    }
3807
3808    #[test]
3809    fn js_string_simple() {
3810        assert_eq!(js_string("hello"), "\"hello\"");
3811    }
3812
3813    #[test]
3814    fn js_string_single_quotes() {
3815        let result = js_string("it's a test");
3816        assert!(result.contains("it's a test"));
3817    }
3818
3819    #[test]
3820    fn js_string_double_quotes() {
3821        let result = js_string(r#"say "hello""#);
3822        assert!(result.contains(r#"\""#));
3823    }
3824
3825    #[test]
3826    fn js_string_backslashes() {
3827        let result = js_string(r"path\to\file");
3828        assert!(result.contains(r"\\"));
3829    }
3830
3831    #[test]
3832    fn js_string_newlines_and_tabs() {
3833        let result = js_string("line1\nline2\ttab");
3834        assert!(result.contains(r"\n"));
3835        assert!(result.contains(r"\t"));
3836        assert!(!result.contains('\n'));
3837    }
3838
3839    #[test]
3840    fn js_string_null_bytes() {
3841        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
3842        let result = js_string(&input);
3843        // serde_json escapes null bytes as
3844        assert!(result.contains("\\u0000"));
3845        assert!(!result.contains('\0'));
3846    }
3847
3848    #[test]
3849    fn js_string_template_literal_injection() {
3850        let result = js_string("`${alert(1)}`");
3851        // Should not contain unescaped backticks that could break template literals
3852        // serde_json wraps in double quotes, so backticks are safe
3853        assert!(result.starts_with('"'));
3854        assert!(result.ends_with('"'));
3855    }
3856
3857    #[test]
3858    fn js_string_unicode_separators() {
3859        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
3860        // JSON strings per RFC 8259, and serde_json passes them through literally.
3861        // Since js_string is used inside JS double-quoted strings (not template
3862        // literals), they are safe in modern JS engines (ES2019+).
3863        let result = js_string("a\u{2028}b\u{2029}c");
3864        // Verify the string is valid JSON that round-trips correctly
3865        let decoded: String = serde_json::from_str(&result).unwrap();
3866        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
3867    }
3868
3869    #[test]
3870    fn js_string_empty() {
3871        assert_eq!(js_string(""), "\"\"");
3872    }
3873
3874    #[test]
3875    fn js_string_html_script_close() {
3876        // </script> in a JS string inside HTML could break out of script tags
3877        let result = js_string("</script><img onerror=alert(1)>");
3878        assert!(result.starts_with('"'));
3879        // The string is JSON-encoded; verify it round-trips safely
3880        let decoded: String = serde_json::from_str(&result).unwrap();
3881        assert_eq!(decoded, "</script><img onerror=alert(1)>");
3882    }
3883
3884    #[test]
3885    fn js_string_very_long() {
3886        let long = "a".repeat(100_000);
3887        let result = js_string(&long);
3888        assert!(result.len() >= 100_002); // quotes + content
3889    }
3890
3891    // ── URL validation tests ────────────────────────────────────────────────
3892
3893    #[test]
3894    fn url_allows_http() {
3895        assert!(validate_url("http://example.com", false).is_ok());
3896    }
3897
3898    #[test]
3899    fn url_allows_https() {
3900        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
3901    }
3902
3903    #[test]
3904    fn url_allows_http_localhost() {
3905        assert!(validate_url("http://localhost:3000", false).is_ok());
3906    }
3907
3908    #[test]
3909    fn url_blocks_file_by_default() {
3910        let err = validate_url("file:///etc/passwd", false).unwrap_err();
3911        assert!(err.contains("file"), "error should mention the file scheme");
3912    }
3913
3914    #[test]
3915    fn url_allows_file_when_opted_in() {
3916        assert!(validate_url("file:///tmp/test.html", true).is_ok());
3917    }
3918
3919    #[test]
3920    fn url_blocks_javascript() {
3921        assert!(validate_url("javascript:alert(1)", false).is_err());
3922    }
3923
3924    #[test]
3925    fn url_blocks_javascript_case_insensitive() {
3926        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
3927    }
3928
3929    #[test]
3930    fn url_blocks_data_scheme() {
3931        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
3932    }
3933
3934    #[test]
3935    fn url_blocks_vbscript() {
3936        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
3937    }
3938
3939    #[test]
3940    fn url_rejects_invalid() {
3941        assert!(validate_url("not a url at all", false).is_err());
3942    }
3943
3944    #[test]
3945    fn url_strips_control_chars() {
3946        // Control characters should be stripped, leaving a valid URL
3947        let input = format!("http://example{}com", '\0');
3948        assert!(validate_url(&input, false).is_ok());
3949    }
3950
3951    // ── CSS color sanitization tests ───────────────────────────────────────
3952
3953    #[test]
3954    fn css_color_valid_hex() {
3955        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
3956        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
3957        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
3958    }
3959
3960    #[test]
3961    fn css_color_valid_rgb() {
3962        assert_eq!(
3963            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
3964            "rgb(255, 0, 0)"
3965        );
3966        assert_eq!(
3967            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
3968            "rgba(0, 0, 0, 0.5)"
3969        );
3970    }
3971
3972    #[test]
3973    fn css_color_valid_named() {
3974        assert_eq!(sanitize_css_color("red").unwrap(), "red");
3975        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
3976    }
3977
3978    #[test]
3979    fn css_color_valid_hsl() {
3980        assert_eq!(
3981            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
3982            "hsl(120, 50%, 50%)"
3983        );
3984    }
3985
3986    #[test]
3987    fn css_color_rejects_too_long() {
3988        let long = "a".repeat(101);
3989        assert!(sanitize_css_color(&long).is_err());
3990    }
3991
3992    #[test]
3993    fn css_color_rejects_backslash_escapes() {
3994        assert!(sanitize_css_color(r"red\00").is_err());
3995        assert!(sanitize_css_color(r"\72\65\64").is_err());
3996    }
3997
3998    #[test]
3999    fn css_color_rejects_url_injection() {
4000        assert!(sanitize_css_color("url(http://evil.com)").is_err());
4001        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4002    }
4003
4004    #[test]
4005    fn css_color_rejects_expression_injection() {
4006        assert!(sanitize_css_color("expression(alert(1))").is_err());
4007        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4008    }
4009
4010    #[test]
4011    fn css_color_rejects_import() {
4012        assert!(sanitize_css_color("@import url(evil.css)").is_err());
4013    }
4014
4015    #[test]
4016    fn css_color_rejects_semicolons_and_braces() {
4017        assert!(sanitize_css_color("red; background: url(evil)").is_err());
4018        assert!(sanitize_css_color("red} body { color: blue").is_err());
4019    }
4020
4021    #[test]
4022    fn css_color_rejects_special_chars() {
4023        assert!(sanitize_css_color("red<script>").is_err());
4024        assert!(sanitize_css_color("red\"onload=alert").is_err());
4025        assert!(sanitize_css_color("red'onclick=alert").is_err());
4026    }
4027
4028    #[test]
4029    fn css_color_trims_whitespace() {
4030        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
4031    }
4032
4033    #[test]
4034    fn css_color_empty_string() {
4035        assert_eq!(sanitize_css_color("").unwrap(), "");
4036    }
4037}