Skip to main content

victauri_plugin/mcp/
mod.rs

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