Skip to main content

victauri_plugin/mcp/
mod.rs

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