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