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, process \
1792            enumeration, 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`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
1802            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
1803            - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
1804            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
1805            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
1806            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
1807            - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
1808            - `event_bus_clear`: Clear the event bus capture buffer.",
1809        annotations(
1810            read_only_hint = true,
1811            destructive_hint = false,
1812            idempotent_hint = true,
1813            open_world_hint = false
1814        )
1815    )]
1816    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
1817        self.track_tool_call();
1818        if !self.state.privacy.is_tool_enabled("introspect") {
1819            return tool_disabled("introspect");
1820        }
1821
1822        match params.action {
1823            IntrospectAction::CommandTimings => {
1824                let mut stats = self.state.command_timings.all_stats();
1825                if let Some(threshold) = params.slow_threshold_ms {
1826                    stats.retain(|s| s.avg_ms >= threshold);
1827                }
1828                let result = serde_json::json!({
1829                    "commands": stats,
1830                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
1831                    "slow_threshold_ms": params.slow_threshold_ms,
1832                });
1833                json_result(&result)
1834            }
1835            IntrospectAction::Coverage => {
1836                let registered: Vec<String> = self
1837                    .state
1838                    .registry
1839                    .list()
1840                    .iter()
1841                    .map(|c| c.name.clone())
1842                    .collect();
1843
1844                let code = "return window.__VICTAURI__?.getIpcLog()";
1845                let invoked: std::collections::HashSet<String> = match self
1846                    .eval_with_return(code, params.webview_label.as_deref())
1847                    .await
1848                {
1849                    Ok(json_str) => {
1850                        if let Ok(entries) =
1851                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
1852                        {
1853                            entries
1854                                .iter()
1855                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
1856                                .map(String::from)
1857                                .collect()
1858                        } else {
1859                            std::collections::HashSet::new()
1860                        }
1861                    }
1862                    Err(_) => std::collections::HashSet::new(),
1863                };
1864
1865                let uncovered: Vec<&String> = registered
1866                    .iter()
1867                    .filter(|cmd| !invoked.contains(cmd.as_str()))
1868                    .collect();
1869
1870                let coverage_pct = if registered.is_empty() {
1871                    100.0
1872                } else {
1873                    let covered = registered.len() - uncovered.len();
1874                    (covered as f64 / registered.len() as f64) * 100.0
1875                };
1876
1877                let result = serde_json::json!({
1878                    "registered_commands": registered.len(),
1879                    "invoked_commands": invoked.len(),
1880                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
1881                    "uncovered": uncovered,
1882                    "invoked_not_registered": invoked.iter()
1883                        .filter(|cmd| !registered.contains(cmd))
1884                        .collect::<Vec<_>>(),
1885                });
1886                json_result(&result)
1887            }
1888            IntrospectAction::ContractRecord => {
1889                let Some(command) = params.command else {
1890                    return missing_param("command", "contract_record");
1891                };
1892                let args_json = params.args.unwrap_or(serde_json::json!({}));
1893                let args_str =
1894                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1895                let code = format!(
1896                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1897                    js_string(&command)
1898                );
1899                match self
1900                    .eval_with_return(&code, params.webview_label.as_deref())
1901                    .await
1902                {
1903                    Ok(result_str) => {
1904                        let value: serde_json::Value = serde_json::from_str(&result_str)
1905                            .unwrap_or(serde_json::Value::String(result_str.clone()));
1906                        let shape = crate::introspection::JsonShape::from_value(&value);
1907                        let sample = if result_str.len() > 4096 {
1908                            format!("{}...(truncated)", &result_str[..4096])
1909                        } else {
1910                            result_str
1911                        };
1912                        let baseline = crate::introspection::ContractBaseline {
1913                            command: command.clone(),
1914                            args: args_json,
1915                            shape: shape.clone(),
1916                            sample,
1917                            recorded_at: chrono_now(),
1918                        };
1919                        self.state.contract_store.record(baseline);
1920                        let result = serde_json::json!({
1921                            "recorded": true,
1922                            "command": command,
1923                            "shape_type": shape.type_name(),
1924                        });
1925                        json_result(&result)
1926                    }
1927                    Err(e) => tool_error(format!(
1928                        "failed to invoke '{command}' for contract recording: {e}"
1929                    )),
1930                }
1931            }
1932            IntrospectAction::ContractCheck => {
1933                let baselines = self.state.contract_store.all();
1934                if baselines.is_empty() {
1935                    return json_result(&serde_json::json!({
1936                        "checked": 0,
1937                        "message": "no contract baselines recorded — use contract_record first",
1938                    }));
1939                }
1940                let mut results = Vec::new();
1941                for baseline in &baselines {
1942                    let args_str =
1943                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
1944                    let code = format!(
1945                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1946                        js_string(&baseline.command)
1947                    );
1948                    match self
1949                        .eval_with_return(&code, params.webview_label.as_deref())
1950                        .await
1951                    {
1952                        Ok(result_str) => {
1953                            let value: serde_json::Value = serde_json::from_str(&result_str)
1954                                .unwrap_or(serde_json::Value::String(result_str));
1955                            let current_shape = crate::introspection::JsonShape::from_value(&value);
1956                            let drift = crate::introspection::diff_shapes(
1957                                &baseline.shape,
1958                                &current_shape,
1959                                &baseline.command,
1960                            );
1961                            results.push(drift);
1962                        }
1963                        Err(e) => {
1964                            results.push(crate::introspection::ContractDrift {
1965                                command: baseline.command.clone(),
1966                                new_fields: Vec::new(),
1967                                removed_fields: Vec::new(),
1968                                type_changes: Vec::new(),
1969                                shape_matches: false,
1970                            });
1971                            tracing::warn!(
1972                                command = %baseline.command,
1973                                error = %e,
1974                                "contract check invocation failed"
1975                            );
1976                        }
1977                    }
1978                }
1979                let passing = results.iter().filter(|r| r.shape_matches).count();
1980                let result = serde_json::json!({
1981                    "checked": results.len(),
1982                    "passing": passing,
1983                    "failing": results.len() - passing,
1984                    "contracts": results,
1985                });
1986                json_result(&result)
1987            }
1988            IntrospectAction::ContractList => {
1989                let baselines = self.state.contract_store.all();
1990                let result = serde_json::json!({
1991                    "count": baselines.len(),
1992                    "baselines": baselines.iter().map(|b| serde_json::json!({
1993                        "command": b.command,
1994                        "shape_type": b.shape.type_name(),
1995                        "recorded_at": b.recorded_at,
1996                    })).collect::<Vec<_>>(),
1997                });
1998                json_result(&result)
1999            }
2000            IntrospectAction::ContractClear => {
2001                let cleared = self.state.contract_store.clear();
2002                json_result(&serde_json::json!({
2003                    "cleared": cleared,
2004                }))
2005            }
2006            IntrospectAction::StartupTiming => {
2007                let phases = self.state.startup_timeline.report();
2008                let result = serde_json::json!({
2009                    "phases": phases,
2010                    "total_ms": self.state.startup_timeline.total_ms(),
2011                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2012                });
2013                json_result(&result)
2014            }
2015            IntrospectAction::Capabilities => {
2016                let config = self.bridge.tauri_config();
2017                let live_windows = self.bridge.list_window_labels();
2018
2019                let result = serde_json::json!({
2020                    "app": {
2021                        "identifier": config.get("identifier"),
2022                        "product_name": config.get("product_name"),
2023                        "version": config.get("version"),
2024                    },
2025                    "security": config.get("security"),
2026                    "configured_windows": config.get("windows"),
2027                    "live_windows": live_windows,
2028                    "configured_plugins": config.get("plugins"),
2029                    "victauri": {
2030                        "registered_commands": self.state.registry.list().len(),
2031                        "auth_enabled": self.state.privacy.redaction_enabled,
2032                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
2033                        "disabled_tools": &self.state.privacy.disabled_tools,
2034                    },
2035                });
2036                json_result(&result)
2037            }
2038            #[allow(unused_variables)]
2039            IntrospectAction::DbHealth => {
2040                #[cfg(feature = "sqlite")]
2041                {
2042                    let db_path = params.db_path.clone();
2043                    match self.run_db_health(db_path.as_deref()).await {
2044                        Ok(health) => json_result(&health),
2045                        Err(e) => tool_error(format!("db_health failed: {e}")),
2046                    }
2047                }
2048                #[cfg(not(feature = "sqlite"))]
2049                {
2050                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2051                }
2052            }
2053            IntrospectAction::PluginState => {
2054                let recording_active = self.state.recorder.is_recording();
2055                let recording_events = self.state.recorder.event_count();
2056                let result = serde_json::json!({
2057                    "event_log": {
2058                        "size": self.state.event_log.len(),
2059                        "capacity": self.state.event_log.capacity(),
2060                    },
2061                    "registry": {
2062                        "commands_registered": self.state.registry.list().len(),
2063                    },
2064                    "recording": {
2065                        "active": recording_active,
2066                        "events_captured": recording_events,
2067                    },
2068                    "faults": {
2069                        "active_rules": self.state.fault_registry.list().len(),
2070                    },
2071                    "contracts": {
2072                        "baselines_recorded": self.state.contract_store.all().len(),
2073                    },
2074                    "timings": {
2075                        "commands_profiled": self.state.command_timings.all_stats().len(),
2076                    },
2077                    "event_bus": {
2078                        "captured_events": self.state.event_bus.len(),
2079                    },
2080                    "tasks": {
2081                        "total": self.state.task_tracker.list().len(),
2082                        "active": self.state.task_tracker.active_count(),
2083                    },
2084                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2085                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2086                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2087                });
2088                json_result(&result)
2089            }
2090            IntrospectAction::Processes => {
2091                let pid = std::process::id();
2092                let uptime = self.state.started_at.elapsed();
2093                let children = crate::introspection::enumerate_child_processes();
2094                let host_memory = crate::memory::current_stats();
2095
2096                let result = serde_json::json!({
2097                    "host": {
2098                        "pid": pid,
2099                        "uptime_secs": uptime.as_secs(),
2100                        "platform": std::env::consts::OS,
2101                        "arch": std::env::consts::ARCH,
2102                        "memory": host_memory,
2103                    },
2104                    "children": children.iter().map(|c| serde_json::json!({
2105                        "pid": c.pid,
2106                        "name": c.name,
2107                        "memory_bytes": c.memory_bytes,
2108                    })).collect::<Vec<_>>(),
2109                    "child_count": children.len(),
2110                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2111                });
2112                json_result(&result)
2113            }
2114            IntrospectAction::PluginTasks => {
2115                let tasks = self.state.task_tracker.list();
2116                let active = self.state.task_tracker.active_count();
2117                let result = serde_json::json!({
2118                    "total": tasks.len(),
2119                    "active": active,
2120                    "finished": tasks.len() - active,
2121                    "tasks": tasks,
2122                });
2123                json_result(&result)
2124            }
2125            IntrospectAction::EventBus => {
2126                let tauri_events = self.state.event_bus.events();
2127                let app_events = self.state.event_log.snapshot();
2128                let result = serde_json::json!({
2129                    "tauri_events": {
2130                        "count": tauri_events.len(),
2131                        "events": tauri_events,
2132                    },
2133                    "app_events": {
2134                        "count": app_events.len(),
2135                        "capacity": self.state.event_log.capacity(),
2136                        "events": app_events,
2137                    },
2138                });
2139                json_result(&result)
2140            }
2141            IntrospectAction::EventBusClear => {
2142                let tauri_cleared = self.state.event_bus.clear();
2143                self.state.event_log.clear();
2144                json_result(&serde_json::json!({
2145                    "tauri_events_cleared": tauri_cleared,
2146                    "app_events_cleared": true,
2147                }))
2148            }
2149        }
2150    }
2151
2152    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
2153
2154    #[tool(
2155        description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2156            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2157            CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2158            Actions:\n\
2159            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2160            - `list`: List all active fault injection rules.\n\
2161            - `clear`: Remove a specific fault rule (requires `command`).\n\
2162            - `clear_all`: Remove all fault rules.",
2163        annotations(
2164            read_only_hint = false,
2165            destructive_hint = true,
2166            idempotent_hint = false,
2167            open_world_hint = false
2168        )
2169    )]
2170    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2171        self.track_tool_call();
2172        if !self.state.privacy.is_tool_enabled("fault") {
2173            return tool_disabled("fault");
2174        }
2175
2176        match params.action {
2177            FaultAction::Inject => {
2178                let Some(command) = params.command else {
2179                    return missing_param("command", "inject");
2180                };
2181                let Some(fault_kind) = params.fault_type else {
2182                    return missing_param("fault_type", "inject");
2183                };
2184                let fault_type = match fault_kind {
2185                    FaultKind::Delay => {
2186                        let delay_ms = params.delay_ms.unwrap_or(1000);
2187                        crate::introspection::FaultType::Delay { delay_ms }
2188                    }
2189                    FaultKind::Error => {
2190                        let message = params
2191                            .error_message
2192                            .unwrap_or_else(|| "injected fault".to_string());
2193                        crate::introspection::FaultType::Error { message }
2194                    }
2195                    FaultKind::Drop => crate::introspection::FaultType::Drop,
2196                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2197                };
2198                let config = crate::introspection::FaultConfig {
2199                    command: command.clone(),
2200                    fault_type: fault_type.clone(),
2201                    trigger_count: 0,
2202                    max_triggers: params.max_triggers.unwrap_or(0),
2203                    created_at: std::time::Instant::now(),
2204                };
2205                self.state.fault_registry.inject(config);
2206                let result = serde_json::json!({
2207                    "injected": true,
2208                    "command": command,
2209                    "fault_type": fault_type,
2210                    "max_triggers": params.max_triggers.unwrap_or(0),
2211                });
2212                json_result(&result)
2213            }
2214            FaultAction::List => {
2215                let faults = self.state.fault_registry.list();
2216                let result = serde_json::json!({
2217                    "count": faults.len(),
2218                    "faults": faults.iter().map(|f| serde_json::json!({
2219                        "command": f.command,
2220                        "fault_type": f.fault_type,
2221                        "trigger_count": f.trigger_count,
2222                        "max_triggers": f.max_triggers,
2223                    })).collect::<Vec<_>>(),
2224                });
2225                json_result(&result)
2226            }
2227            FaultAction::Clear => {
2228                let Some(command) = params.command else {
2229                    return missing_param("command", "clear");
2230                };
2231                let removed = self.state.fault_registry.clear(&command);
2232                json_result(&serde_json::json!({
2233                    "removed": removed,
2234                    "command": command,
2235                }))
2236            }
2237            FaultAction::ClearAll => {
2238                let removed = self.state.fault_registry.clear_all();
2239                json_result(&serde_json::json!({
2240                    "removed": removed,
2241                }))
2242            }
2243        }
2244    }
2245
2246    // ── Cross-Layer Explanation ────────────────────────────────────────────
2247
2248    #[tool(
2249        description = "Correlate recent activity across all layers into a coherent narrative. \
2250            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2251            + window events across the Rust backend and webview simultaneously.\n\n\
2252            Actions:\n\
2253            - `summary`: High-level activity summary for the last N seconds (default 30). \
2254              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2255            - `last_action`: Correlate the most recent burst of events into a causal timeline \
2256              (e.g. 'IPC call → DOM update → console.log').\n\
2257            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2258        annotations(
2259            read_only_hint = true,
2260            destructive_hint = false,
2261            idempotent_hint = true,
2262            open_world_hint = false
2263        )
2264    )]
2265    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2266        self.track_tool_call();
2267        if !self.state.privacy.is_tool_enabled("explain") {
2268            return tool_disabled("explain");
2269        }
2270
2271        match params.action {
2272            ExplainAction::Summary => {
2273                let secs = params.seconds.unwrap_or(30);
2274                let since = chrono::Utc::now()
2275                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2276                let events = self.state.event_log.since(since);
2277
2278                let mut ipc_count = 0u64;
2279                let mut dom_mutations = 0u64;
2280                let mut state_changes = 0u64;
2281                let mut window_events = 0u64;
2282                let mut interactions = 0u64;
2283                let mut top_commands: HashMap<String, u64> = HashMap::new();
2284                let mut errors: Vec<String> = Vec::new();
2285
2286                for event in &events {
2287                    match event {
2288                        victauri_core::AppEvent::Ipc(call) => {
2289                            ipc_count += 1;
2290                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2291                            if let victauri_core::IpcResult::Err(e) = &call.result {
2292                                errors.push(format!("IPC {}: {e}", call.command));
2293                            }
2294                        }
2295                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2296                            dom_mutations += u64::from(*mutation_count)
2297                        }
2298                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2299                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2300                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2301                        _ => {}
2302                    }
2303                }
2304
2305                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2306                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2307                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2308
2309                let narrative = format!(
2310                    "{ipc_count} IPC call{} in the last {secs}s{}. \
2311                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2312                     {window_events} window event{}. {}.",
2313                    if ipc_count == 1 { "" } else { "s" },
2314                    if top.is_empty() {
2315                        String::new()
2316                    } else {
2317                        format!(
2318                            ", dominated by {}",
2319                            top.iter()
2320                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2321                                .collect::<Vec<_>>()
2322                                .join(", ")
2323                        )
2324                    },
2325                    if dom_mutations == 1 { "" } else { "s" },
2326                    if interactions == 1 { "" } else { "s" },
2327                    if window_events == 1 { "" } else { "s" },
2328                    if errors.is_empty() {
2329                        "No errors".to_string()
2330                    } else {
2331                        format!(
2332                            "{} error{}",
2333                            errors.len(),
2334                            if errors.len() == 1 { "" } else { "s" }
2335                        )
2336                    },
2337                );
2338
2339                let result = serde_json::json!({
2340                    "time_window_secs": secs,
2341                    "total_events": events.len(),
2342                    "ipc_calls": ipc_count,
2343                    "dom_mutations": dom_mutations,
2344                    "state_changes": state_changes,
2345                    "window_events": window_events,
2346                    "interactions": interactions,
2347                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2348                        serde_json::json!({"command": cmd, "count": n})
2349                    }).collect::<Vec<_>>(),
2350                    "errors": errors,
2351                    "narrative": narrative,
2352                });
2353                json_result(&result)
2354            }
2355            ExplainAction::LastAction => {
2356                let secs = params.seconds.unwrap_or(5);
2357                let since = chrono::Utc::now()
2358                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2359                let events = self.state.event_log.since(since);
2360
2361                let timeline: Vec<serde_json::Value> = events
2362                    .iter()
2363                    .map(|event| match event {
2364                        victauri_core::AppEvent::Ipc(call) => {
2365                            serde_json::json!({
2366                                "time": call.timestamp.to_rfc3339_opts(
2367                                    chrono::SecondsFormat::Millis, true
2368                                ),
2369                                "type": "ipc",
2370                                "detail": format!(
2371                                    "{} {} ({}ms)",
2372                                    call.command,
2373                                    call.result,
2374                                    call.duration_ms.unwrap_or(0)
2375                                ),
2376                            })
2377                        }
2378                        victauri_core::AppEvent::DomMutation {
2379                            timestamp,
2380                            mutation_count,
2381                            webview_label,
2382                        } => {
2383                            serde_json::json!({
2384                                "time": timestamp.to_rfc3339_opts(
2385                                    chrono::SecondsFormat::Millis, true
2386                                ),
2387                                "type": "dom_mutation",
2388                                "detail": format!(
2389                                    "{mutation_count} element{} updated in {webview_label}",
2390                                    if *mutation_count == 1 { "" } else { "s" }
2391                                ),
2392                            })
2393                        }
2394                        victauri_core::AppEvent::DomInteraction {
2395                            timestamp,
2396                            action,
2397                            selector,
2398                            ..
2399                        } => {
2400                            serde_json::json!({
2401                                "time": timestamp.to_rfc3339_opts(
2402                                    chrono::SecondsFormat::Millis, true
2403                                ),
2404                                "type": "interaction",
2405                                "detail": format!("{action} on {selector}"),
2406                            })
2407                        }
2408                        victauri_core::AppEvent::StateChange {
2409                            timestamp,
2410                            key,
2411                            caused_by,
2412                        } => {
2413                            serde_json::json!({
2414                                "time": timestamp.to_rfc3339_opts(
2415                                    chrono::SecondsFormat::Millis, true
2416                                ),
2417                                "type": "state_change",
2418                                "detail": format!(
2419                                    "{key} changed{}",
2420                                    caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2421                                ),
2422                            })
2423                        }
2424                        victauri_core::AppEvent::WindowEvent {
2425                            timestamp,
2426                            label,
2427                            event,
2428                        } => {
2429                            serde_json::json!({
2430                                "time": timestamp.to_rfc3339_opts(
2431                                    chrono::SecondsFormat::Millis, true
2432                                ),
2433                                "type": "window_event",
2434                                "detail": format!("{event} on window '{label}'"),
2435                            })
2436                        }
2437                        _ => {
2438                            serde_json::json!({
2439                                "time": event.timestamp().to_rfc3339_opts(
2440                                    chrono::SecondsFormat::Millis, true
2441                                ),
2442                                "type": "other",
2443                                "detail": "unknown event type",
2444                            })
2445                        }
2446                    })
2447                    .collect();
2448
2449                let narrative = if timeline.is_empty() {
2450                    format!("No activity in the last {secs}s.")
2451                } else {
2452                    let parts: Vec<String> = timeline
2453                        .iter()
2454                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2455                        .map(String::from)
2456                        .collect();
2457                    parts.join(" → ")
2458                };
2459
2460                let result = serde_json::json!({
2461                    "time_window_secs": secs,
2462                    "event_count": timeline.len(),
2463                    "timeline": timeline,
2464                    "narrative": narrative,
2465                });
2466                json_result(&result)
2467            }
2468            ExplainAction::Diff => {
2469                let secs = params.seconds.unwrap_or(10);
2470                let since = chrono::Utc::now()
2471                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2472                let events = self.state.event_log.since(since);
2473
2474                let mut ipc_commands: Vec<String> = Vec::new();
2475                let mut dom_changes = 0u64;
2476                let mut error_count = 0u64;
2477                let mut interaction_count = 0u64;
2478
2479                for event in &events {
2480                    match event {
2481                        victauri_core::AppEvent::Ipc(call) => {
2482                            ipc_commands.push(call.command.clone());
2483                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2484                                error_count += 1;
2485                            }
2486                        }
2487                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2488                            dom_changes += u64::from(*mutation_count)
2489                        }
2490                        victauri_core::AppEvent::DomInteraction { .. } => {
2491                            interaction_count += 1;
2492                        }
2493                        _ => {}
2494                    }
2495                }
2496
2497                ipc_commands.dedup();
2498
2499                let result = serde_json::json!({
2500                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2501                    "time_window_secs": secs,
2502                    "total_events": events.len(),
2503                    "ipc_calls_made": ipc_commands.len(),
2504                    "unique_commands": ipc_commands,
2505                    "dom_elements_changed": dom_changes,
2506                    "interactions": interaction_count,
2507                    "errors": error_count,
2508                });
2509                json_result(&result)
2510            }
2511        }
2512    }
2513}
2514
2515impl VictauriMcpHandler {
2516    /// Create a new handler backed by the given state and webview bridge.
2517    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2518        Self {
2519            state,
2520            bridge,
2521            subscriptions: Arc::new(Mutex::new(HashSet::new())),
2522            bridge_checked: Arc::new(AtomicBool::new(false)),
2523        }
2524    }
2525
2526    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2527        self.state.privacy.is_tool_enabled(name)
2528    }
2529
2530    pub(crate) async fn execute_tool(
2531        &self,
2532        name: &str,
2533        args: serde_json::Value,
2534    ) -> Result<CallToolResult, rest::ToolCallError> {
2535        if !self.state.privacy.is_tool_enabled(name) {
2536            return Ok(tool_disabled(name));
2537        }
2538        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2539        let start = std::time::Instant::now();
2540        tracing::debug!(tool = %name, "REST tool invocation started");
2541
2542        let result = match name {
2543            "eval_js" => {
2544                let p: EvalJsParams = Self::parse_args(args)?;
2545                self.eval_js(Parameters(p)).await
2546            }
2547            "dom_snapshot" => {
2548                let p: SnapshotParams = Self::parse_args(args)?;
2549                self.dom_snapshot(Parameters(p)).await
2550            }
2551            "find_elements" => {
2552                let p: FindElementsParams = Self::parse_args(args)?;
2553                self.find_elements(Parameters(p)).await
2554            }
2555            "invoke_command" => {
2556                let p: InvokeCommandParams = Self::parse_args(args)?;
2557                self.invoke_command(Parameters(p)).await
2558            }
2559            "screenshot" => {
2560                let p: ScreenshotParams = Self::parse_args(args)?;
2561                self.screenshot(Parameters(p)).await
2562            }
2563            "verify_state" => {
2564                let p: VerifyStateParams = Self::parse_args(args)?;
2565                self.verify_state(Parameters(p)).await
2566            }
2567            "detect_ghost_commands" => {
2568                let p: GhostCommandParams = Self::parse_args(args)?;
2569                self.detect_ghost_commands(Parameters(p)).await
2570            }
2571            "check_ipc_integrity" => {
2572                let p: IpcIntegrityParams = Self::parse_args(args)?;
2573                self.check_ipc_integrity(Parameters(p)).await
2574            }
2575            "wait_for" => {
2576                let p: WaitForParams = Self::parse_args(args)?;
2577                self.wait_for(Parameters(p)).await
2578            }
2579            "assert_semantic" => {
2580                let p: SemanticAssertParams = Self::parse_args(args)?;
2581                self.assert_semantic(Parameters(p)).await
2582            }
2583            "resolve_command" => {
2584                let p: ResolveCommandParams = Self::parse_args(args)?;
2585                self.resolve_command(Parameters(p)).await
2586            }
2587            "get_registry" => {
2588                let p: RegistryParams = Self::parse_args(args)?;
2589                self.get_registry(Parameters(p)).await
2590            }
2591            "get_memory_stats" => self.get_memory_stats().await,
2592            "get_plugin_info" => self.get_plugin_info().await,
2593            "get_diagnostics" => {
2594                let p: DiagnosticsParams = Self::parse_args(args)?;
2595                self.get_diagnostics(Parameters(p)).await
2596            }
2597            "app_info" => self.app_info().await,
2598            "list_app_dir" => {
2599                let p: ListAppDirParams = Self::parse_args(args)?;
2600                self.list_app_dir(Parameters(p)).await
2601            }
2602            "read_app_file" => {
2603                let p: ReadAppFileParams = Self::parse_args(args)?;
2604                self.read_app_file(Parameters(p)).await
2605            }
2606            #[cfg(feature = "sqlite")]
2607            "query_db" => {
2608                let p: QueryDbParams = Self::parse_args(args)?;
2609                self.query_db(Parameters(p)).await
2610            }
2611            "interact" => {
2612                let p: InteractParams = Self::parse_args(args)?;
2613                self.interact(Parameters(p)).await
2614            }
2615            "input" => {
2616                let p: InputParams = Self::parse_args(args)?;
2617                self.input(Parameters(p)).await
2618            }
2619            "window" => {
2620                let p: WindowParams = Self::parse_args(args)?;
2621                self.window(Parameters(p)).await
2622            }
2623            "storage" => {
2624                let p: StorageParams = Self::parse_args(args)?;
2625                self.storage(Parameters(p)).await
2626            }
2627            "navigate" => {
2628                let p: NavigateParams = Self::parse_args(args)?;
2629                self.navigate(Parameters(p)).await
2630            }
2631            "recording" => {
2632                let p: RecordingParams = Self::parse_args(args)?;
2633                self.recording(Parameters(p)).await
2634            }
2635            "inspect" => {
2636                let p: InspectParams = Self::parse_args(args)?;
2637                self.inspect(Parameters(p)).await
2638            }
2639            "css" => {
2640                let p: CssParams = Self::parse_args(args)?;
2641                self.css(Parameters(p)).await
2642            }
2643            "logs" => {
2644                let p: LogsParams = Self::parse_args(args)?;
2645                self.logs(Parameters(p)).await
2646            }
2647            "introspect" => {
2648                let p: IntrospectParams = Self::parse_args(args)?;
2649                self.introspect(Parameters(p)).await
2650            }
2651            "fault" => {
2652                let p: FaultParams = Self::parse_args(args)?;
2653                self.fault(Parameters(p)).await
2654            }
2655            "explain" => {
2656                let p: ExplainParams = Self::parse_args(args)?;
2657                self.explain(Parameters(p)).await
2658            }
2659            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
2660        };
2661
2662        let elapsed = start.elapsed();
2663        tracing::debug!(
2664            tool = %name,
2665            elapsed_ms = elapsed.as_millis() as u64,
2666            "REST tool invocation completed"
2667        );
2668
2669        if self.state.privacy.redaction_enabled {
2670            Ok(Self::redact_result(result, &self.state.privacy))
2671        } else {
2672            Ok(result)
2673        }
2674    }
2675
2676    fn parse_args<T: serde::de::DeserializeOwned>(
2677        args: serde_json::Value,
2678    ) -> Result<T, rest::ToolCallError> {
2679        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
2680    }
2681
2682    fn redact_result(
2683        mut result: CallToolResult,
2684        privacy: &crate::privacy::PrivacyConfig,
2685    ) -> CallToolResult {
2686        for item in &mut result.content {
2687            if let RawContent::Text(ref mut tc) = item.raw {
2688                tc.text = privacy.redact_output(&tc.text);
2689            }
2690        }
2691        result
2692    }
2693
2694    fn track_tool_call(&self) {
2695        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2696    }
2697
2698    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
2699        match dir.unwrap_or(AppDir::Data) {
2700            AppDir::Data => self.bridge.app_data_dir(),
2701            AppDir::Config => self.bridge.app_config_dir(),
2702            AppDir::Log => self.bridge.app_log_dir(),
2703            AppDir::LocalData => self.bridge.app_local_data_dir(),
2704        }
2705    }
2706
2707    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
2708        let canon_base = std::fs::canonicalize(base)
2709            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
2710        let canon_target = std::fs::canonicalize(target)
2711            .map_err(|e| format!("cannot resolve target path: {e}"))?;
2712        if !canon_target.starts_with(&canon_base) {
2713            return Err("path traversal not allowed".to_string());
2714        }
2715        Ok(())
2716    }
2717
2718    fn list_dir_recursive(
2719        dir: &std::path::Path,
2720        base: &std::path::Path,
2721        depth: u32,
2722        max_depth: u32,
2723        pattern: Option<&str>,
2724        entries: &mut Vec<serde_json::Value>,
2725    ) {
2726        let Ok(read_dir) = std::fs::read_dir(dir) else {
2727            return;
2728        };
2729        for entry in read_dir.flatten() {
2730            let path = entry.path();
2731            if path.is_symlink() {
2732                continue;
2733            }
2734            let name = entry.file_name().to_string_lossy().into_owned();
2735            let relative = path
2736                .strip_prefix(base)
2737                .unwrap_or(&path)
2738                .to_string_lossy()
2739                .into_owned();
2740
2741            if let Some(pat) = pattern
2742                && !Self::matches_glob(&name, pat)
2743                && !path.is_dir()
2744            {
2745                continue;
2746            }
2747
2748            let is_dir = path.is_dir();
2749            let meta = std::fs::metadata(&path).ok();
2750
2751            entries.push(serde_json::json!({
2752                "name": name,
2753                "path": relative,
2754                "is_dir": is_dir,
2755                "size": meta.as_ref().map(std::fs::Metadata::len),
2756                "modified": meta.as_ref()
2757                    .and_then(|m| m.modified().ok())
2758                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
2759                        .unwrap_or_default().as_secs()),
2760            }));
2761
2762            if is_dir && depth < max_depth {
2763                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
2764            }
2765        }
2766    }
2767
2768    fn matches_glob(name: &str, pattern: &str) -> bool {
2769        if pattern == "*" {
2770            return true;
2771        }
2772        if let Some(suffix) = pattern.strip_prefix("*.") {
2773            return name.ends_with(&format!(".{suffix}"));
2774        }
2775        if let Some(prefix) = pattern.strip_suffix("*") {
2776            return name.starts_with(prefix);
2777        }
2778        name == pattern
2779    }
2780
2781    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
2782        match self.eval_with_return(code, webview_label).await {
2783            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2784            Err(e) => tool_error(e),
2785        }
2786    }
2787
2788    async fn eval_with_return(
2789        &self,
2790        code: &str,
2791        webview_label: Option<&str>,
2792    ) -> Result<String, String> {
2793        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
2794            .await
2795    }
2796
2797    async fn eval_with_return_timeout(
2798        &self,
2799        code: &str,
2800        webview_label: Option<&str>,
2801        timeout: std::time::Duration,
2802    ) -> Result<String, String> {
2803        self.track_tool_call();
2804        let id = uuid::Uuid::new_v4().to_string();
2805        let (tx, rx) = tokio::sync::oneshot::channel();
2806
2807        {
2808            let mut pending = self.state.pending_evals.lock().await;
2809            if pending.len() >= MAX_PENDING_EVALS {
2810                return Err(format!(
2811                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
2812                ));
2813            }
2814            pending.insert(id.clone(), tx);
2815        }
2816
2817        // Auto-prepend `return` so bare expressions produce a value.
2818        // Only skip for code that starts with a statement keyword where
2819        // prepending `return` would be a syntax error.
2820        let code = code.trim();
2821        let needs_return = !code.starts_with("return ")
2822            && !code.starts_with("return;")
2823            && !code.starts_with('{')
2824            && !code.starts_with("if ")
2825            && !code.starts_with("if(")
2826            && !code.starts_with("for ")
2827            && !code.starts_with("for(")
2828            && !code.starts_with("while ")
2829            && !code.starts_with("while(")
2830            && !code.starts_with("switch ")
2831            && !code.starts_with("try ")
2832            && !code.starts_with("const ")
2833            && !code.starts_with("let ")
2834            && !code.starts_with("var ")
2835            && !code.starts_with("function ")
2836            && !code.starts_with("class ")
2837            && !code.starts_with("throw ");
2838        let code = if needs_return {
2839            format!("return {code}")
2840        } else {
2841            code.to_string()
2842        };
2843
2844        let id_js = js_string(&id);
2845        let inject = format!(
2846            r"
2847            (async () => {{
2848                try {{
2849                    const __result = await (async () => {{ {code} }})();
2850                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2851                        id: {id_js},
2852                        result: JSON.stringify(__result)
2853                    }});
2854                }} catch (e) {{
2855                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2856                        id: {id_js},
2857                        result: JSON.stringify({{ __error: e.message }})
2858                    }});
2859                }}
2860            }})();
2861            "
2862        );
2863
2864        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
2865            self.state.pending_evals.lock().await.remove(&id);
2866            return Err(format!("eval injection failed: {e}"));
2867        }
2868
2869        match tokio::time::timeout(timeout, rx).await {
2870            Ok(Ok(result)) => {
2871                self.check_bridge_version_once();
2872                Ok(result)
2873            }
2874            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
2875            Err(_) => {
2876                self.state.pending_evals.lock().await.remove(&id);
2877                Err(format!("eval timed out after {}s", timeout.as_secs()))
2878            }
2879        }
2880    }
2881
2882    #[cfg(feature = "sqlite")]
2883    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
2884        let data_dir = self.bridge.app_data_dir()?;
2885        let path = if let Some(p) = db_path {
2886            data_dir.join(p)
2887        } else {
2888            let mut found = None;
2889            if let Ok(entries) = std::fs::read_dir(&data_dir) {
2890                for entry in entries.flatten() {
2891                    let p = entry.path();
2892                    if p.extension()
2893                        .is_some_and(|ext| ext == "db" || ext == "sqlite" || ext == "sqlite3")
2894                    {
2895                        found = Some(p);
2896                        break;
2897                    }
2898                }
2899            }
2900            found.ok_or_else(|| "no database found in app data directory".to_string())?
2901        };
2902        Self::safe_within(&data_dir, &path)?;
2903
2904        let path_str = path
2905            .to_str()
2906            .ok_or_else(|| "invalid path encoding".to_string())?
2907            .to_string();
2908
2909        tokio::task::spawn_blocking(move || {
2910            let conn = rusqlite::Connection::open_with_flags(
2911                &path_str,
2912                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2913            )
2914            .map_err(|e| format!("cannot open database: {e}"))?;
2915
2916            let journal_mode: String = conn
2917                .pragma_query_value(None, "journal_mode", |r| r.get(0))
2918                .unwrap_or_else(|_| "unknown".to_string());
2919
2920            let page_count: i64 = conn
2921                .pragma_query_value(None, "page_count", |r| r.get(0))
2922                .unwrap_or(0);
2923
2924            let page_size: i64 = conn
2925                .pragma_query_value(None, "page_size", |r| r.get(0))
2926                .unwrap_or(0);
2927
2928            let freelist_count: i64 = conn
2929                .pragma_query_value(None, "freelist_count", |r| r.get(0))
2930                .unwrap_or(0);
2931
2932            let wal_checkpoint: String = if journal_mode == "wal" {
2933                let mut info = String::from("n/a");
2934                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
2935                    let busy: i64 = r.get(0)?;
2936                    let checkpointed: i64 = r.get(1)?;
2937                    let total: i64 = r.get(2)?;
2938                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
2939                    Ok(())
2940                });
2941                info
2942            } else {
2943                "n/a (not WAL mode)".to_string()
2944            };
2945
2946            let integrity: String = conn
2947                .pragma_query_value(None, "quick_check", |r| r.get(0))
2948                .unwrap_or_else(|_| "failed".to_string());
2949
2950            let db_size_bytes = page_count * page_size;
2951            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
2952
2953            let mut tables = Vec::new();
2954            if let Ok(mut stmt) =
2955                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
2956                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
2957            {
2958                for name in rows.flatten() {
2959                    let count: i64 = conn
2960                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
2961                        .unwrap_or(0);
2962                    tables.push(serde_json::json!({
2963                        "name": name,
2964                        "row_count": count,
2965                    }));
2966                }
2967            }
2968
2969            Ok(serde_json::json!({
2970                "database": path_str,
2971                "journal_mode": journal_mode,
2972                "page_count": page_count,
2973                "page_size": page_size,
2974                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
2975                "freelist_count": freelist_count,
2976                "wal_checkpoint": wal_checkpoint,
2977                "integrity_check": integrity,
2978                "tables": tables,
2979            }))
2980        })
2981        .await
2982        .map_err(|e| format!("db health task failed: {e}"))?
2983    }
2984
2985    fn check_bridge_version_once(&self) {
2986        if self.bridge_checked.swap(true, Ordering::Relaxed) {
2987            return;
2988        }
2989        let handler = self.clone();
2990        tokio::spawn(async move {
2991            match handler
2992                .eval_with_return_timeout(
2993                    "window.__VICTAURI__?.version",
2994                    None,
2995                    std::time::Duration::from_secs(5),
2996                )
2997                .await
2998            {
2999                Ok(v) => {
3000                    let v = v.trim_matches('"');
3001                    if v == BRIDGE_VERSION {
3002                        tracing::debug!("Bridge version verified: {v}");
3003                    } else {
3004                        tracing::warn!(
3005                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3006                        );
3007                    }
3008                }
3009                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3010            }
3011        });
3012    }
3013}
3014
3015const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3016It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3017(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3018(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3019\n\nBACKEND tools (direct Rust access, no webview needed): \
3020'app_info' (app config, directory paths, discovered databases, process info), \
3021'list_app_dir' (browse app data/config/log directories), \
3022'read_app_file' (read files from app directories), \
3023'query_db' (read-only SQLite queries with auto-discovery). \
3024\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3025'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3026capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3027Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3028capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3029task tracking, and automatic Tauri event bus monitoring. \
3030'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3031drops, and response corruption into Tauri commands at the Rust layer. \
3032'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3033activity across IPC + DOM + console + network + window events into a coherent narrative. \
3034\n\nWEBVIEW tools: \
3035'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3036'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3037'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3038\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3039\n\nCOMPOUND tools with an 'action' parameter: \
3040'window' (get_state, list, manage, resize, move_to, set_title), \
3041'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3042set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3043get_events, events_between, get_replay, export, import, replay), \
3044'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3045\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3046get_memory_stats, get_plugin_info, get_diagnostics.";
3047
3048impl ServerHandler for VictauriMcpHandler {
3049    fn get_info(&self) -> ServerInfo {
3050        ServerInfo::new(
3051            ServerCapabilities::builder()
3052                .enable_tools()
3053                .enable_resources()
3054                .enable_resources_subscribe()
3055                .build(),
3056        )
3057        .with_instructions(SERVER_INSTRUCTIONS)
3058    }
3059
3060    async fn list_tools(
3061        &self,
3062        _request: Option<PaginatedRequestParams>,
3063        _context: RequestContext<RoleServer>,
3064    ) -> Result<ListToolsResult, ErrorData> {
3065        let all_tools = Self::tool_router().list_all();
3066        let filtered: Vec<Tool> = all_tools
3067            .into_iter()
3068            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3069            .collect();
3070        Ok(ListToolsResult {
3071            tools: filtered,
3072            ..Default::default()
3073        })
3074    }
3075
3076    async fn call_tool(
3077        &self,
3078        request: CallToolRequestParams,
3079        context: RequestContext<RoleServer>,
3080    ) -> Result<CallToolResult, ErrorData> {
3081        let tool_name: String = request.name.as_ref().to_owned();
3082        if !self.state.privacy.is_tool_enabled(&tool_name) {
3083            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3084            return Ok(tool_disabled(&tool_name));
3085        }
3086        self.state
3087            .tool_invocations
3088            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3089        let start = std::time::Instant::now();
3090        tracing::debug!(tool = %tool_name, "tool invocation started");
3091        let ctx = ToolCallContext::new(self, request, context);
3092        let result = Self::tool_router().call(ctx).await;
3093        let elapsed = start.elapsed();
3094        tracing::debug!(
3095            tool = %tool_name,
3096            elapsed_ms = elapsed.as_millis() as u64,
3097            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3098            "tool invocation completed"
3099        );
3100
3101        // Centralized output redaction: apply to all text content so no
3102        // individual tool can accidentally leak secrets.
3103        if self.state.privacy.redaction_enabled {
3104            result.map(|mut r| {
3105                for item in &mut r.content {
3106                    if let RawContent::Text(ref mut tc) = item.raw {
3107                        tc.text = self.state.privacy.redact_output(&tc.text);
3108                    }
3109                }
3110                r
3111            })
3112        } else {
3113            result
3114        }
3115    }
3116
3117    fn get_tool(&self, name: &str) -> Option<Tool> {
3118        if !self.state.privacy.is_tool_enabled(name) {
3119            return None;
3120        }
3121        Self::tool_router().get(name).cloned()
3122    }
3123
3124    async fn list_resources(
3125        &self,
3126        _request: Option<PaginatedRequestParams>,
3127        _context: RequestContext<RoleServer>,
3128    ) -> Result<ListResourcesResult, ErrorData> {
3129        Ok(ListResourcesResult {
3130            resources: vec![
3131                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3132                    .with_description(
3133                        "Live IPC call log — all commands invoked between frontend and backend",
3134                    )
3135                    .with_mime_type("application/json")
3136                    .no_annotation(),
3137                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3138                    .with_description(
3139                        "Current state of all Tauri windows — position, size, visibility, focus",
3140                    )
3141                    .with_mime_type("application/json")
3142                    .no_annotation(),
3143                RawResource::new(RESOURCE_URI_STATE, "state")
3144                    .with_description(
3145                        "Victauri plugin state — event count, registered commands, memory stats",
3146                    )
3147                    .with_mime_type("application/json")
3148                    .no_annotation(),
3149            ],
3150            ..Default::default()
3151        })
3152    }
3153
3154    async fn read_resource(
3155        &self,
3156        request: ReadResourceRequestParams,
3157        _context: RequestContext<RoleServer>,
3158    ) -> Result<ReadResourceResult, ErrorData> {
3159        let uri = &request.uri;
3160        let json = match uri.as_str() {
3161            RESOURCE_URI_IPC_LOG => {
3162                if let Ok(json) = self
3163                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3164                    .await
3165                {
3166                    json
3167                } else {
3168                    let calls = self.state.event_log.ipc_calls();
3169                    serde_json::to_string_pretty(&calls)
3170                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3171                }
3172            }
3173            RESOURCE_URI_WINDOWS => {
3174                let states = self.bridge.get_window_states(None);
3175                serde_json::to_string_pretty(&states)
3176                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3177            }
3178            RESOURCE_URI_STATE => {
3179                let state_json = serde_json::json!({
3180                    "events_captured": self.state.event_log.len(),
3181                    "commands_registered": self.state.registry.count(),
3182                    "memory": crate::memory::current_stats(),
3183                    "port": self.state.port.load(Ordering::Relaxed),
3184                });
3185                serde_json::to_string_pretty(&state_json)
3186                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3187            }
3188            _ => {
3189                return Err(ErrorData::resource_not_found(
3190                    format!("unknown resource: {uri}"),
3191                    None,
3192                ));
3193            }
3194        };
3195
3196        let json = if self.state.privacy.redaction_enabled {
3197            self.state.privacy.redact_output(&json)
3198        } else {
3199            json
3200        };
3201
3202        Ok(ReadResourceResult::new(vec![ResourceContents::text(
3203            json, uri,
3204        )]))
3205    }
3206
3207    async fn subscribe(
3208        &self,
3209        request: SubscribeRequestParams,
3210        _context: RequestContext<RoleServer>,
3211    ) -> Result<(), ErrorData> {
3212        let uri = &request.uri;
3213        match uri.as_str() {
3214            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3215                self.subscriptions.lock().await.insert(uri.clone());
3216                tracing::info!("Client subscribed to resource: {uri}");
3217                Ok(())
3218            }
3219            _ => Err(ErrorData::resource_not_found(
3220                format!("unknown resource: {uri}"),
3221                None,
3222            )),
3223        }
3224    }
3225
3226    async fn unsubscribe(
3227        &self,
3228        request: UnsubscribeRequestParams,
3229        _context: RequestContext<RoleServer>,
3230    ) -> Result<(), ErrorData> {
3231        self.subscriptions.lock().await.remove(&request.uri);
3232        tracing::info!("Client unsubscribed from resource: {}", request.uri);
3233        Ok(())
3234    }
3235}
3236
3237#[cfg(test)]
3238mod tests {
3239    use super::*;
3240
3241    #[test]
3242    fn js_string_simple() {
3243        assert_eq!(js_string("hello"), "\"hello\"");
3244    }
3245
3246    #[test]
3247    fn js_string_single_quotes() {
3248        let result = js_string("it's a test");
3249        assert!(result.contains("it's a test"));
3250    }
3251
3252    #[test]
3253    fn js_string_double_quotes() {
3254        let result = js_string(r#"say "hello""#);
3255        assert!(result.contains(r#"\""#));
3256    }
3257
3258    #[test]
3259    fn js_string_backslashes() {
3260        let result = js_string(r"path\to\file");
3261        assert!(result.contains(r"\\"));
3262    }
3263
3264    #[test]
3265    fn js_string_newlines_and_tabs() {
3266        let result = js_string("line1\nline2\ttab");
3267        assert!(result.contains(r"\n"));
3268        assert!(result.contains(r"\t"));
3269        assert!(!result.contains('\n'));
3270    }
3271
3272    #[test]
3273    fn js_string_null_bytes() {
3274        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
3275        let result = js_string(&input);
3276        // serde_json escapes null bytes as
3277        assert!(result.contains("\\u0000"));
3278        assert!(!result.contains('\0'));
3279    }
3280
3281    #[test]
3282    fn js_string_template_literal_injection() {
3283        let result = js_string("`${alert(1)}`");
3284        // Should not contain unescaped backticks that could break template literals
3285        // serde_json wraps in double quotes, so backticks are safe
3286        assert!(result.starts_with('"'));
3287        assert!(result.ends_with('"'));
3288    }
3289
3290    #[test]
3291    fn js_string_unicode_separators() {
3292        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
3293        // JSON strings per RFC 8259, and serde_json passes them through literally.
3294        // Since js_string is used inside JS double-quoted strings (not template
3295        // literals), they are safe in modern JS engines (ES2019+).
3296        let result = js_string("a\u{2028}b\u{2029}c");
3297        // Verify the string is valid JSON that round-trips correctly
3298        let decoded: String = serde_json::from_str(&result).unwrap();
3299        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
3300    }
3301
3302    #[test]
3303    fn js_string_empty() {
3304        assert_eq!(js_string(""), "\"\"");
3305    }
3306
3307    #[test]
3308    fn js_string_html_script_close() {
3309        // </script> in a JS string inside HTML could break out of script tags
3310        let result = js_string("</script><img onerror=alert(1)>");
3311        assert!(result.starts_with('"'));
3312        // The string is JSON-encoded; verify it round-trips safely
3313        let decoded: String = serde_json::from_str(&result).unwrap();
3314        assert_eq!(decoded, "</script><img onerror=alert(1)>");
3315    }
3316
3317    #[test]
3318    fn js_string_very_long() {
3319        let long = "a".repeat(100_000);
3320        let result = js_string(&long);
3321        assert!(result.len() >= 100_002); // quotes + content
3322    }
3323
3324    // ── URL validation tests ────────────────────────────────────────────────
3325
3326    #[test]
3327    fn url_allows_http() {
3328        assert!(validate_url("http://example.com", false).is_ok());
3329    }
3330
3331    #[test]
3332    fn url_allows_https() {
3333        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
3334    }
3335
3336    #[test]
3337    fn url_allows_http_localhost() {
3338        assert!(validate_url("http://localhost:3000", false).is_ok());
3339    }
3340
3341    #[test]
3342    fn url_blocks_file_by_default() {
3343        let err = validate_url("file:///etc/passwd", false).unwrap_err();
3344        assert!(err.contains("file"), "error should mention the file scheme");
3345    }
3346
3347    #[test]
3348    fn url_allows_file_when_opted_in() {
3349        assert!(validate_url("file:///tmp/test.html", true).is_ok());
3350    }
3351
3352    #[test]
3353    fn url_blocks_javascript() {
3354        assert!(validate_url("javascript:alert(1)", false).is_err());
3355    }
3356
3357    #[test]
3358    fn url_blocks_javascript_case_insensitive() {
3359        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
3360    }
3361
3362    #[test]
3363    fn url_blocks_data_scheme() {
3364        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
3365    }
3366
3367    #[test]
3368    fn url_blocks_vbscript() {
3369        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
3370    }
3371
3372    #[test]
3373    fn url_rejects_invalid() {
3374        assert!(validate_url("not a url at all", false).is_err());
3375    }
3376
3377    #[test]
3378    fn url_strips_control_chars() {
3379        // Control characters should be stripped, leaving a valid URL
3380        let input = format!("http://example{}com", '\0');
3381        assert!(validate_url(&input, false).is_ok());
3382    }
3383
3384    // ── CSS color sanitization tests ───────────────────────────────────────
3385
3386    #[test]
3387    fn css_color_valid_hex() {
3388        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
3389        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
3390        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
3391    }
3392
3393    #[test]
3394    fn css_color_valid_rgb() {
3395        assert_eq!(
3396            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
3397            "rgb(255, 0, 0)"
3398        );
3399        assert_eq!(
3400            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
3401            "rgba(0, 0, 0, 0.5)"
3402        );
3403    }
3404
3405    #[test]
3406    fn css_color_valid_named() {
3407        assert_eq!(sanitize_css_color("red").unwrap(), "red");
3408        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
3409    }
3410
3411    #[test]
3412    fn css_color_valid_hsl() {
3413        assert_eq!(
3414            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
3415            "hsl(120, 50%, 50%)"
3416        );
3417    }
3418
3419    #[test]
3420    fn css_color_rejects_too_long() {
3421        let long = "a".repeat(101);
3422        assert!(sanitize_css_color(&long).is_err());
3423    }
3424
3425    #[test]
3426    fn css_color_rejects_backslash_escapes() {
3427        assert!(sanitize_css_color(r"red\00").is_err());
3428        assert!(sanitize_css_color(r"\72\65\64").is_err());
3429    }
3430
3431    #[test]
3432    fn css_color_rejects_url_injection() {
3433        assert!(sanitize_css_color("url(http://evil.com)").is_err());
3434        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
3435    }
3436
3437    #[test]
3438    fn css_color_rejects_expression_injection() {
3439        assert!(sanitize_css_color("expression(alert(1))").is_err());
3440        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
3441    }
3442
3443    #[test]
3444    fn css_color_rejects_import() {
3445        assert!(sanitize_css_color("@import url(evil.css)").is_err());
3446    }
3447
3448    #[test]
3449    fn css_color_rejects_semicolons_and_braces() {
3450        assert!(sanitize_css_color("red; background: url(evil)").is_err());
3451        assert!(sanitize_css_color("red} body { color: blue").is_err());
3452    }
3453
3454    #[test]
3455    fn css_color_rejects_special_chars() {
3456        assert!(sanitize_css_color("red<script>").is_err());
3457        assert!(sanitize_css_color("red\"onload=alert").is_err());
3458        assert!(sanitize_css_color("red'onclick=alert").is_err());
3459    }
3460
3461    #[test]
3462    fn css_color_trims_whitespace() {
3463        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
3464    }
3465
3466    #[test]
3467    fn css_color_empty_string() {
3468        assert_eq!(sanitize_css_color("").unwrap(), "");
3469    }
3470}