Skip to main content

victauri_plugin/mcp/
mod.rs

1mod backend_params;
2mod compound_params;
3mod helpers;
4mod other_params;
5mod rest;
6mod server;
7mod verification_params;
8mod webview_params;
9mod window_params;
10
11use std::collections::HashSet;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use rmcp::handler::server::tool::ToolCallContext;
16use rmcp::handler::server::wrapper::Parameters;
17use rmcp::model::{
18    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
19    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
20    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
21    Tool, UnsubscribeRequestParams,
22};
23use rmcp::service::RequestContext;
24use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
25use tokio::sync::Mutex;
26
27use crate::VictauriState;
28use crate::bridge::WebviewBridge;
29
30use helpers::{
31    js_string, json_result, missing_param, sanitize_css_color, tool_disabled, tool_error,
32    validate_url,
33};
34
35pub use backend_params::*;
36pub use compound_params::*;
37pub use other_params::{
38    DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
39    WaitCondition, WaitForParams,
40};
41pub use server::*;
42pub use verification_params::*;
43pub use webview_params::*;
44pub use window_params::*;
45
46// ── MCP Handler ──────────────────────────────────────────────────────────────
47
48/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
49/// growth of the `pending_evals` map if callbacks are never resolved.
50pub(crate) const MAX_PENDING_EVALS: usize = 100;
51
52/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
53const MAX_EVAL_CODE_LEN: usize = 1_000_000;
54
55const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
56const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
57const RESOURCE_URI_STATE: &str = "victauri://state";
58
59const BRIDGE_VERSION: &str = "0.4.0";
60
61const SAFE_ENV_PREFIXES: &[&str] = &[
62    "PATH",
63    "HOME",
64    "USER",
65    "LANG",
66    "LC_",
67    "TERM",
68    "SHELL",
69    "DISPLAY",
70    "XDG_",
71    "TAURI_",
72    "VICTAURI_",
73    "RUST",
74    "CARGO",
75    "NODE_ENV",
76    "APPDATA",
77    "LOCALAPPDATA",
78    "USERPROFILE",
79    "TEMP",
80    "TMP",
81    "PROGRAMFILES",
82    "SYSTEMROOT",
83    "WINDIR",
84    "COMSPEC",
85    "OS",
86    "PROCESSOR_",
87    "NUMBER_OF_PROCESSORS",
88    "COMPUTERNAME",
89    "HOSTNAME",
90    "PWD",
91    "OLDPWD",
92    "SHLVL",
93    "LOGNAME",
94];
95
96/// MCP tool handler that dispatches tool calls to the webview bridge and state.
97#[derive(Clone)]
98pub struct VictauriMcpHandler {
99    state: Arc<VictauriState>,
100    bridge: Arc<dyn WebviewBridge>,
101    subscriptions: Arc<Mutex<HashSet<String>>>,
102    bridge_checked: Arc<AtomicBool>,
103}
104
105#[tool_router]
106impl VictauriMcpHandler {
107    // ── Standalone Tools ────────────────────────────────────────────────────
108
109    #[tool(
110        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
111        annotations(
112            read_only_hint = false,
113            destructive_hint = true,
114            idempotent_hint = false,
115            open_world_hint = false
116        )
117    )]
118    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
119        if !self.state.privacy.is_tool_enabled("eval_js") {
120            return tool_disabled("eval_js");
121        }
122        if params.code.len() > MAX_EVAL_CODE_LEN {
123            return tool_error("code exceeds maximum length (1 MB)");
124        }
125        match self
126            .eval_with_return(&params.code, params.webview_label.as_deref())
127            .await
128        {
129            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
130            Err(e) => tool_error(e),
131        }
132    }
133
134    #[tool(
135        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).",
136        annotations(
137            read_only_hint = true,
138            destructive_hint = false,
139            idempotent_hint = true,
140            open_world_hint = false
141        )
142    )]
143    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
144        let format = params.format.unwrap_or(SnapshotFormat::Compact);
145        let format_str = match format {
146            SnapshotFormat::Compact => "compact",
147            SnapshotFormat::Json => "json",
148        };
149        let code = format!(
150            "return window.__VICTAURI__?.snapshot({})",
151            js_string(format_str)
152        );
153        self.eval_bridge(&code, params.webview_label.as_deref())
154            .await
155    }
156
157    #[tool(
158        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.",
159        annotations(
160            read_only_hint = true,
161            destructive_hint = false,
162            idempotent_hint = true,
163            open_world_hint = false
164        )
165    )]
166    async fn find_elements(
167        &self,
168        Parameters(params): Parameters<FindElementsParams>,
169    ) -> CallToolResult {
170        let mut parts: Vec<String> = Vec::new();
171        if let Some(t) = &params.text {
172            parts.push(format!("text: {}", js_string(t)));
173        }
174        if let Some(r) = &params.role {
175            parts.push(format!("role: {}", js_string(r)));
176        }
177        if let Some(tid) = &params.test_id {
178            parts.push(format!("test_id: {}", js_string(tid)));
179        }
180        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
181            parts.push(format!("css: {}", js_string(c)));
182        }
183        if let Some(n) = &params.name {
184            parts.push(format!("name: {}", js_string(n)));
185        }
186        if let Some(max) = params.max_results {
187            parts.push(format!("max_results: {max}"));
188        }
189        if let Some(t) = &params.tag {
190            parts.push(format!("tag: {}", js_string(t)));
191        }
192        if let Some(p) = &params.placeholder {
193            parts.push(format!("placeholder: {}", js_string(p)));
194        }
195        if let Some(a) = &params.alt {
196            parts.push(format!("alt: {}", js_string(a)));
197        }
198        if let Some(ta) = &params.title_attr {
199            parts.push(format!("title_attr: {}", js_string(ta)));
200        }
201        if let Some(l) = &params.label {
202            parts.push(format!("label: {}", js_string(l)));
203        }
204        if let Some(true) = params.exact {
205            parts.push("exact: true".to_string());
206        }
207        if let Some(e) = params.enabled {
208            parts.push(format!("enabled: {e}"));
209        }
210        let code = format!(
211            "return window.__VICTAURI__?.findElements({{ {} }})",
212            parts.join(", ")
213        );
214        self.eval_bridge(&code, params.webview_label.as_deref())
215            .await
216    }
217
218    #[tool(
219        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.",
220        annotations(
221            read_only_hint = false,
222            destructive_hint = true,
223            idempotent_hint = false,
224            open_world_hint = false
225        )
226    )]
227    async fn invoke_command(
228        &self,
229        Parameters(params): Parameters<InvokeCommandParams>,
230    ) -> CallToolResult {
231        if !self.state.privacy.is_invoke_allowed(&params.command) {
232            return tool_disabled("invoke_command");
233        }
234        if !self.state.privacy.is_command_allowed(&params.command) {
235            return tool_error(format!(
236                "command '{}' is blocked by privacy configuration",
237                params.command
238            ));
239        }
240        let args_json = params.args.unwrap_or(serde_json::json!({}));
241        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
242        let code = format!(
243            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
244            js_string(&params.command)
245        );
246        match self
247            .eval_with_return(&code, params.webview_label.as_deref())
248            .await
249        {
250            Ok(result) => {
251                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
252                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
253                {
254                    return tool_error(format!(
255                        "command '{}' returned error: {err}",
256                        params.command
257                    ));
258                }
259                CallToolResult::success(vec![Content::text(result)])
260            }
261            Err(e) => tool_error(format!("invoke_command failed: {e}")),
262        }
263    }
264
265    #[tool(
266        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
267        annotations(
268            read_only_hint = true,
269            destructive_hint = false,
270            idempotent_hint = true,
271            open_world_hint = false
272        )
273    )]
274    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
275        self.track_tool_call();
276        if !self.state.privacy.is_tool_enabled("screenshot") {
277            return tool_disabled("screenshot");
278        }
279        match self
280            .bridge
281            .get_native_handle(params.window_label.as_deref())
282        {
283            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
284                Ok(png_bytes) => {
285                    use base64::Engine;
286                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
287                    CallToolResult::success(vec![Content::image(b64, "image/png")])
288                }
289                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
290            },
291            Err(e) => tool_error(format!("cannot get window handle: {e}")),
292        }
293    }
294
295    #[tool(
296        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
297        annotations(
298            read_only_hint = true,
299            destructive_hint = false,
300            idempotent_hint = true,
301            open_world_hint = false
302        )
303    )]
304    async fn verify_state(
305        &self,
306        Parameters(params): Parameters<VerifyStateParams>,
307    ) -> CallToolResult {
308        if !self.state.privacy.is_tool_enabled("eval_js") {
309            return tool_disabled("verify_state requires eval_js capability");
310        }
311        let code = format!("return ({})", params.frontend_expr);
312        let frontend_json = match self
313            .eval_with_return(&code, params.webview_label.as_deref())
314            .await
315        {
316            Ok(result) => result,
317            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
318        };
319
320        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
321            Ok(v) => v,
322            Err(e) => {
323                return tool_error(format!(
324                    "frontend expression did not return valid JSON: {e}"
325                ));
326            }
327        };
328
329        let backend_state = if let Some(state) = params.backend_state {
330            state
331        } else if let Some(ref cmd) = params.backend_command {
332            if !self.state.privacy.is_command_allowed(cmd) {
333                return tool_error(format!(
334                    "command '{cmd}' is blocked by privacy configuration"
335                ));
336            }
337            let args = params.backend_args.unwrap_or(serde_json::json!({}));
338            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
339            let invoke_code = format!(
340                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
341                js_string(cmd)
342            );
343            match self
344                .eval_with_return(&invoke_code, params.webview_label.as_deref())
345                .await
346            {
347                Ok(result) => match serde_json::from_str(&result) {
348                    Ok(v) => v,
349                    Err(e) => {
350                        return tool_error(format!(
351                            "backend command '{cmd}' did not return valid JSON: {e}"
352                        ));
353                    }
354                },
355                Err(e) => {
356                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
357                }
358            }
359        } else {
360            return tool_error("either backend_state or backend_command must be provided");
361        };
362
363        let result = victauri_core::verify_state(frontend_state, backend_state);
364        json_result(&result)
365    }
366
367    #[tool(
368        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.",
369        annotations(
370            read_only_hint = true,
371            destructive_hint = false,
372            idempotent_hint = true,
373            open_world_hint = false
374        )
375    )]
376    async fn detect_ghost_commands(
377        &self,
378        Parameters(params): Parameters<GhostCommandParams>,
379    ) -> CallToolResult {
380        let code = "return window.__VICTAURI__?.getIpcLog()";
381        let ipc_json = match self
382            .eval_with_return(code, params.webview_label.as_deref())
383            .await
384        {
385            Ok(r) => r,
386            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
387        };
388
389        let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
390            Ok(v) => v,
391            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
392        };
393        let frontend_commands: Vec<String> = ipc_calls
394            .iter()
395            .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
396            .collect::<std::collections::HashSet<_>>()
397            .into_iter()
398            .collect();
399
400        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
401        json_result(&report)
402    }
403
404    #[tool(
405        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
406        annotations(
407            read_only_hint = true,
408            destructive_hint = false,
409            idempotent_hint = true,
410            open_world_hint = false
411        )
412    )]
413    async fn check_ipc_integrity(
414        &self,
415        Parameters(params): Parameters<IpcIntegrityParams>,
416    ) -> CallToolResult {
417        let threshold = params.stale_threshold_ms.unwrap_or(5000);
418        let code = format!(
419            r"return (function() {{
420                var log = window.__VICTAURI__?.getIpcLog() || [];
421                var now = Date.now();
422                var threshold = {threshold};
423                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
424                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
425                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
426                return {{
427                    healthy: stale.length === 0 && errored.length === 0,
428                    total_calls: log.length,
429                    pending_count: pending.length,
430                    stale_count: stale.length,
431                    error_count: errored.length,
432                    stale_calls: stale.slice(0, 20),
433                    errored_calls: errored.slice(0, 20)
434                }};
435            }})()"
436        );
437        self.eval_bridge(&code, params.webview_label.as_deref())
438            .await
439    }
440
441    #[tool(
442        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).",
443        annotations(
444            read_only_hint = true,
445            destructive_hint = false,
446            idempotent_hint = true,
447            open_world_hint = false
448        )
449    )]
450    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
451        let value = params
452            .value
453            .as_ref()
454            .map_or_else(|| "null".to_string(), |v| js_string(v));
455        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
456        let poll = params.poll_ms.unwrap_or(200);
457        let code = format!(
458            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
459            js_string(params.condition.as_str())
460        );
461        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
462        match self
463            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
464            .await
465        {
466            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
467            Err(e) => tool_error(e),
468        }
469    }
470
471    #[tool(
472        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.",
473        annotations(
474            read_only_hint = true,
475            destructive_hint = false,
476            idempotent_hint = true,
477            open_world_hint = false
478        )
479    )]
480    async fn assert_semantic(
481        &self,
482        Parameters(params): Parameters<SemanticAssertParams>,
483    ) -> CallToolResult {
484        if !self.state.privacy.is_tool_enabled("eval_js") {
485            return tool_disabled("assert_semantic requires eval_js capability");
486        }
487        let code = format!("return ({})", params.expression);
488        let actual_json = match self
489            .eval_with_return(&code, params.webview_label.as_deref())
490            .await
491        {
492            Ok(result) => result,
493            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
494        };
495
496        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
497            Ok(v) => v,
498            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
499        };
500
501        let assertion = victauri_core::SemanticAssertion {
502            label: params.label,
503            condition: params.condition,
504            expected: params.expected,
505        };
506
507        let result = victauri_core::evaluate_assertion(actual, &assertion);
508        json_result(&result)
509    }
510
511    #[tool(
512        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
513        annotations(
514            read_only_hint = true,
515            destructive_hint = false,
516            idempotent_hint = true,
517            open_world_hint = false
518        )
519    )]
520    async fn resolve_command(
521        &self,
522        Parameters(params): Parameters<ResolveCommandParams>,
523    ) -> CallToolResult {
524        self.track_tool_call();
525        let limit = params.limit.unwrap_or(5);
526        let mut results = self.state.registry.resolve(&params.query);
527        results.truncate(limit);
528        json_result(&results)
529    }
530
531    #[tool(
532        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.",
533        annotations(
534            read_only_hint = true,
535            destructive_hint = false,
536            idempotent_hint = true,
537            open_world_hint = false
538        )
539    )]
540    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
541        self.track_tool_call();
542        let commands = match params.query {
543            Some(q) => self.state.registry.search(&q),
544            None => self.state.registry.list(),
545        };
546        json_result(&commands)
547    }
548
549    #[tool(
550        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.",
551        annotations(
552            read_only_hint = true,
553            destructive_hint = false,
554            idempotent_hint = true,
555            open_world_hint = false
556        )
557    )]
558    async fn get_memory_stats(&self) -> CallToolResult {
559        self.track_tool_call();
560        let stats = crate::memory::current_stats();
561        json_result(&stats)
562    }
563
564    #[tool(
565        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.",
566        annotations(
567            read_only_hint = true,
568            destructive_hint = false,
569            idempotent_hint = true,
570            open_world_hint = false
571        )
572    )]
573    async fn get_plugin_info(&self) -> CallToolResult {
574        self.track_tool_call();
575        let disabled: Vec<&str> = self
576            .state
577            .privacy
578            .disabled_tools
579            .iter()
580            .map(std::string::String::as_str)
581            .collect();
582        let blocklist: Vec<&str> = self
583            .state
584            .privacy
585            .command_blocklist
586            .iter()
587            .map(std::string::String::as_str)
588            .collect();
589        let allowlist: Option<Vec<&str>> = self
590            .state
591            .privacy
592            .command_allowlist
593            .as_ref()
594            .map(|s| s.iter().map(std::string::String::as_str).collect());
595        let all_tools = Self::tool_router().list_all();
596        let enabled_tools: Vec<&str> = all_tools
597            .iter()
598            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
599            .map(|t| t.name.as_ref())
600            .collect();
601
602        let result = serde_json::json!({
603            "version": env!("CARGO_PKG_VERSION"),
604            "bridge_version": BRIDGE_VERSION,
605            "port": self.state.port.load(Ordering::Relaxed),
606            "tools": {
607                "total": all_tools.len(),
608                "enabled": enabled_tools.len(),
609                "enabled_list": enabled_tools,
610                "disabled_list": disabled,
611            },
612            "commands": {
613                "allowlist": allowlist,
614                "blocklist": blocklist,
615            },
616            "privacy": {
617                "profile": self.state.privacy.profile.to_string(),
618                "redaction_enabled": self.state.privacy.redaction_enabled,
619            },
620            "capacities": {
621                "event_log": self.state.event_log.capacity(),
622                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
623            },
624            "registered_commands": self.state.registry.count(),
625            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
626            "uptime_secs": self.state.started_at.elapsed().as_secs(),
627        });
628        json_result(&result)
629    }
630
631    #[tool(
632        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.",
633        annotations(
634            read_only_hint = true,
635            destructive_hint = false,
636            idempotent_hint = true,
637            open_world_hint = false
638        )
639    )]
640    async fn get_diagnostics(
641        &self,
642        Parameters(params): Parameters<DiagnosticsParams>,
643    ) -> CallToolResult {
644        self.eval_bridge(
645            "return window.__VICTAURI__?.getDiagnostics()",
646            params.webview_label.as_deref(),
647        )
648        .await
649    }
650
651    // ── Backend Access Tools ───────────────────────────────────────────────
652
653    #[tool(
654        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.",
655        annotations(
656            read_only_hint = true,
657            destructive_hint = false,
658            idempotent_hint = true,
659            open_world_hint = false
660        )
661    )]
662    async fn app_info(&self) -> CallToolResult {
663        self.track_tool_call();
664        let config = self.bridge.tauri_config();
665
666        let data_dir = self.bridge.app_data_dir().ok();
667        let config_dir = self.bridge.app_config_dir().ok();
668        let log_dir = self.bridge.app_log_dir().ok();
669        let local_data_dir = self.bridge.app_local_data_dir().ok();
670
671        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
672            .filter(|(k, _)| {
673                let upper = k.to_uppercase();
674                SAFE_ENV_PREFIXES
675                    .iter()
676                    .any(|prefix| upper.starts_with(prefix))
677            })
678            .collect();
679
680        #[cfg(feature = "sqlite")]
681        let databases: Vec<String> = data_dir
682            .as_ref()
683            .map(|d| {
684                crate::database::discover_databases(d)
685                    .into_iter()
686                    .filter_map(|p| {
687                        p.strip_prefix(d)
688                            .ok()
689                            .map(|rel| rel.to_string_lossy().into_owned())
690                    })
691                    .collect()
692            })
693            .unwrap_or_default();
694
695        #[cfg(not(feature = "sqlite"))]
696        let databases: Vec<String> = Vec::new();
697
698        let result = serde_json::json!({
699            "config": config,
700            "paths": {
701                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
702                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
703                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
704                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
705            },
706            "databases": databases,
707            "env": env_vars,
708            "process": {
709                "pid": std::process::id(),
710                "arch": std::env::consts::ARCH,
711                "os": std::env::consts::OS,
712                "family": std::env::consts::FAMILY,
713            },
714        });
715        json_result(&result)
716    }
717
718    #[tool(
719        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.",
720        annotations(
721            read_only_hint = true,
722            destructive_hint = false,
723            idempotent_hint = true,
724            open_world_hint = false
725        )
726    )]
727    async fn list_app_dir(
728        &self,
729        Parameters(params): Parameters<ListAppDirParams>,
730    ) -> CallToolResult {
731        self.track_tool_call();
732        let base = match self.resolve_app_dir(params.directory) {
733            Ok(d) => d,
734            Err(e) => return tool_error(e),
735        };
736
737        let target = if let Some(ref sub) = params.path {
738            let resolved = base.join(sub);
739            if !resolved.exists() {
740                return tool_error(format!("directory does not exist: {}", resolved.display()));
741            }
742            if let Err(e) = Self::safe_within(&base, &resolved) {
743                return tool_error(e);
744            }
745            resolved
746        } else {
747            base.clone()
748        };
749
750        if !target.exists() {
751            return tool_error(format!("directory does not exist: {}", target.display()));
752        }
753
754        let max_depth = params.max_depth.unwrap_or(1).min(5);
755        let pattern = params.pattern.as_deref();
756        let mut entries = Vec::new();
757
758        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
759
760        json_result(&serde_json::json!({
761            "base": base.to_string_lossy(),
762            "path": params.path.unwrap_or_default(),
763            "entries": entries,
764            "count": entries.len(),
765        }))
766    }
767
768    #[tool(
769        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.",
770        annotations(
771            read_only_hint = true,
772            destructive_hint = false,
773            idempotent_hint = true,
774            open_world_hint = false
775        )
776    )]
777    async fn read_app_file(
778        &self,
779        Parameters(params): Parameters<ReadAppFileParams>,
780    ) -> CallToolResult {
781        self.track_tool_call();
782        let base = match self.resolve_app_dir(params.directory) {
783            Ok(d) => d,
784            Err(e) => return tool_error(e),
785        };
786
787        let target = base.join(&params.path);
788        if !target.exists() {
789            return tool_error(format!("file not found: {}", params.path));
790        }
791        if let Err(e) = Self::safe_within(&base, &target) {
792            return tool_error(e);
793        }
794        if !target.is_file() {
795            return tool_error(format!("not a file: {}", params.path));
796        }
797
798        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
799        let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
800
801        match std::fs::read(&target) {
802            Ok(mut bytes) => {
803                let original_size = bytes.len();
804                let truncated = bytes.len() > max_bytes;
805                if truncated {
806                    bytes.truncate(max_bytes);
807                }
808
809                let file_info = serde_json::json!({
810                    "path": params.path,
811                    "size": original_size,
812                    "truncated": truncated,
813                    "modified": metadata.as_ref().ok()
814                        .and_then(|m| m.modified().ok())
815                        .map(|t| {
816                            let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
817                            duration.as_secs()
818                        }),
819                });
820
821                if params.binary == Some(true) {
822                    use base64::Engine;
823                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
824                    json_result(&serde_json::json!({
825                        "file": file_info,
826                        "encoding": "base64",
827                        "content": b64,
828                    }))
829                } else {
830                    match String::from_utf8(bytes) {
831                        Ok(text) => json_result(&serde_json::json!({
832                            "file": file_info,
833                            "encoding": "utf-8",
834                            "content": text,
835                        })),
836                        Err(e) => {
837                            use base64::Engine;
838                            let bytes = e.into_bytes();
839                            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
840                            json_result(&serde_json::json!({
841                                "file": file_info,
842                                "encoding": "base64",
843                                "note": "file is not valid UTF-8, returning base64",
844                                "content": b64,
845                            }))
846                        }
847                    }
848                }
849            }
850            Err(e) => tool_error(format!("failed to read file: {e}")),
851        }
852    }
853
854    #[cfg(feature = "sqlite")]
855    #[tool(
856        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.",
857        annotations(
858            read_only_hint = true,
859            destructive_hint = false,
860            idempotent_hint = true,
861            open_world_hint = false
862        )
863    )]
864    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
865        self.track_tool_call();
866        let data_dir = match self.bridge.app_data_dir() {
867            Ok(d) => d,
868            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
869        };
870
871        let db_path = if let Some(ref rel_path) = params.path {
872            let resolved = data_dir.join(rel_path);
873            if !resolved.exists() {
874                return tool_error(format!("database not found: {rel_path}"));
875            }
876            if let Err(e) = Self::safe_within(&data_dir, &resolved) {
877                return tool_error(e);
878            }
879            resolved
880        } else {
881            let databases = crate::database::discover_databases(&data_dir);
882            match databases.first() {
883                Some(p) => p.clone(),
884                None => {
885                    return tool_error(format!(
886                        "no SQLite databases found in {}",
887                        data_dir.display()
888                    ));
889                }
890            }
891        };
892
893        let db_display = db_path
894            .strip_prefix(&data_dir)
895            .unwrap_or(&db_path)
896            .to_string_lossy()
897            .into_owned();
898        let bind_params = params.params.unwrap_or_default();
899
900        match crate::database::query(&db_path, &params.query, &bind_params, params.max_rows) {
901            Ok(mut result) => {
902                if let Some(obj) = result.as_object_mut() {
903                    obj.insert("database".to_string(), serde_json::json!(db_display));
904                }
905                json_result(&result)
906            }
907            Err(e) => tool_error(e),
908        }
909    }
910
911    // ── Compound Tools ──────────────────────────────────────────────────────
912
913    #[tool(
914        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.",
915        annotations(
916            read_only_hint = false,
917            destructive_hint = false,
918            idempotent_hint = false,
919            open_world_hint = false
920        )
921    )]
922    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
923        if !self.state.privacy.is_tool_enabled("interact") {
924            return tool_disabled("interact");
925        }
926        match params.action {
927            InteractAction::Click => {
928                if !self.state.privacy.is_tool_enabled("interact.click") {
929                    return tool_disabled("interact.click");
930                }
931                let Some(ref_id) = &params.ref_id else {
932                    return missing_param("ref_id", "click");
933                };
934                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
935                self.eval_bridge(&code, params.webview_label.as_deref())
936                    .await
937            }
938            InteractAction::DoubleClick => {
939                if !self.state.privacy.is_tool_enabled("interact.double_click") {
940                    return tool_disabled("interact.double_click");
941                }
942                let Some(ref_id) = &params.ref_id else {
943                    return missing_param("ref_id", "double_click");
944                };
945                let code = format!(
946                    "return window.__VICTAURI__?.doubleClick({})",
947                    js_string(ref_id)
948                );
949                self.eval_bridge(&code, params.webview_label.as_deref())
950                    .await
951            }
952            InteractAction::Hover => {
953                if !self.state.privacy.is_tool_enabled("interact.hover") {
954                    return tool_disabled("interact.hover");
955                }
956                let Some(ref_id) = &params.ref_id else {
957                    return missing_param("ref_id", "hover");
958                };
959                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
960                self.eval_bridge(&code, params.webview_label.as_deref())
961                    .await
962            }
963            InteractAction::Focus => {
964                if !self.state.privacy.is_tool_enabled("interact.focus") {
965                    return tool_disabled("interact.focus");
966                }
967                let Some(ref_id) = &params.ref_id else {
968                    return missing_param("ref_id", "focus");
969                };
970                let code = format!(
971                    "return window.__VICTAURI__?.focusElement({})",
972                    js_string(ref_id)
973                );
974                self.eval_bridge(&code, params.webview_label.as_deref())
975                    .await
976            }
977            InteractAction::ScrollIntoView => {
978                if !self
979                    .state
980                    .privacy
981                    .is_tool_enabled("interact.scroll_into_view")
982                {
983                    return tool_disabled("interact.scroll_into_view");
984                }
985                let ref_arg = params
986                    .ref_id
987                    .as_ref()
988                    .map_or_else(|| "null".to_string(), |r| js_string(r));
989                let x = params.x.unwrap_or(0.0);
990                let y = params.y.unwrap_or(0.0);
991                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
992                self.eval_bridge(&code, params.webview_label.as_deref())
993                    .await
994            }
995            InteractAction::SelectOption => {
996                if !self.state.privacy.is_tool_enabled("interact.select_option") {
997                    return tool_disabled("interact.select_option");
998                }
999                let Some(ref_id) = &params.ref_id else {
1000                    return missing_param("ref_id", "select_option");
1001                };
1002                let values = params.values.as_deref().unwrap_or(&[]);
1003                let values_json =
1004                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1005                let code = format!(
1006                    "return window.__VICTAURI__?.selectOption({}, {})",
1007                    js_string(ref_id),
1008                    values_json
1009                );
1010                self.eval_bridge(&code, params.webview_label.as_deref())
1011                    .await
1012            }
1013        }
1014    }
1015
1016    #[tool(
1017        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.",
1018        annotations(
1019            read_only_hint = false,
1020            destructive_hint = false,
1021            idempotent_hint = false,
1022            open_world_hint = false
1023        )
1024    )]
1025    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1026        match params.action {
1027            InputAction::Fill => {
1028                if !self.state.privacy.is_tool_enabled("fill") {
1029                    return tool_disabled("fill");
1030                }
1031                let Some(ref_id) = &params.ref_id else {
1032                    return missing_param("ref_id", "fill");
1033                };
1034                let Some(value) = &params.value else {
1035                    return missing_param("value", "fill");
1036                };
1037                let code = format!(
1038                    "return window.__VICTAURI__?.fill({}, {})",
1039                    js_string(ref_id),
1040                    js_string(value)
1041                );
1042                self.eval_bridge(&code, params.webview_label.as_deref())
1043                    .await
1044            }
1045            InputAction::TypeText => {
1046                if !self.state.privacy.is_tool_enabled("type_text") {
1047                    return tool_disabled("type_text");
1048                }
1049                let Some(ref_id) = &params.ref_id else {
1050                    return missing_param("ref_id", "type_text");
1051                };
1052                let Some(text) = &params.text else {
1053                    return missing_param("text", "type_text");
1054                };
1055                let code = format!(
1056                    "return window.__VICTAURI__?.type({}, {})",
1057                    js_string(ref_id),
1058                    js_string(text)
1059                );
1060                self.eval_bridge(&code, params.webview_label.as_deref())
1061                    .await
1062            }
1063            InputAction::PressKey => {
1064                if !self.state.privacy.is_tool_enabled("input.press_key") {
1065                    return tool_disabled("input.press_key");
1066                }
1067                let Some(key) = &params.key else {
1068                    return missing_param("key", "press_key");
1069                };
1070                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1071                self.eval_bridge(&code, params.webview_label.as_deref())
1072                    .await
1073            }
1074        }
1075    }
1076
1077    #[tool(
1078        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.",
1079        annotations(
1080            read_only_hint = false,
1081            destructive_hint = false,
1082            idempotent_hint = true,
1083            open_world_hint = false
1084        )
1085    )]
1086    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1087        self.track_tool_call();
1088        match params.action {
1089            WindowAction::GetState => {
1090                let states = self.bridge.get_window_states(params.label.as_deref());
1091                json_result(&states)
1092            }
1093            WindowAction::List => {
1094                let labels = self.bridge.list_window_labels();
1095                json_result(&labels)
1096            }
1097            WindowAction::Manage => {
1098                if !self.state.privacy.is_tool_enabled("window.manage") {
1099                    return tool_disabled("window.manage");
1100                }
1101                let Some(manage_action) = &params.manage_action else {
1102                    return missing_param("manage_action", "manage");
1103                };
1104                match self
1105                    .bridge
1106                    .manage_window(params.label.as_deref(), manage_action.as_str())
1107                {
1108                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1109                    Err(e) => tool_error(e),
1110                }
1111            }
1112            WindowAction::Resize => {
1113                if !self.state.privacy.is_tool_enabled("window.resize") {
1114                    return tool_disabled("window.resize");
1115                }
1116                let Some(width) = params.width else {
1117                    return missing_param("width", "resize");
1118                };
1119                let Some(height) = params.height else {
1120                    return missing_param("height", "resize");
1121                };
1122                match self
1123                    .bridge
1124                    .resize_window(params.label.as_deref(), width, height)
1125                {
1126                    Ok(()) => {
1127                        let result =
1128                            serde_json::json!({"ok": true, "width": width, "height": height});
1129                        CallToolResult::success(vec![Content::text(result.to_string())])
1130                    }
1131                    Err(e) => tool_error(e),
1132                }
1133            }
1134            WindowAction::MoveTo => {
1135                if !self.state.privacy.is_tool_enabled("window.move_to") {
1136                    return tool_disabled("window.move_to");
1137                }
1138                let Some(x) = params.x else {
1139                    return missing_param("x", "move_to");
1140                };
1141                let Some(y) = params.y else {
1142                    return missing_param("y", "move_to");
1143                };
1144                match self.bridge.move_window(params.label.as_deref(), x, y) {
1145                    Ok(()) => {
1146                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1147                        CallToolResult::success(vec![Content::text(result.to_string())])
1148                    }
1149                    Err(e) => tool_error(e),
1150                }
1151            }
1152            WindowAction::SetTitle => {
1153                if !self.state.privacy.is_tool_enabled("window.set_title") {
1154                    return tool_disabled("window.set_title");
1155                }
1156                let Some(title) = &params.title else {
1157                    return missing_param("title", "set_title");
1158                };
1159                match self.bridge.set_window_title(params.label.as_deref(), title) {
1160                    Ok(()) => {
1161                        let result = serde_json::json!({"ok": true, "title": title});
1162                        CallToolResult::success(vec![Content::text(result.to_string())])
1163                    }
1164                    Err(e) => tool_error(e),
1165                }
1166            }
1167        }
1168    }
1169
1170    #[tool(
1171        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1172        annotations(
1173            read_only_hint = false,
1174            destructive_hint = true,
1175            idempotent_hint = false,
1176            open_world_hint = false
1177        )
1178    )]
1179    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1180        match params.action {
1181            StorageAction::Get => {
1182                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1183                    StorageType::Session => "getSessionStorage",
1184                    StorageType::Local => "getLocalStorage",
1185                };
1186                let key_arg = params
1187                    .key
1188                    .as_ref()
1189                    .map(|k| js_string(k))
1190                    .unwrap_or_default();
1191                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1192                self.eval_bridge(&code, params.webview_label.as_deref())
1193                    .await
1194            }
1195            StorageAction::Set => {
1196                if !self.state.privacy.is_tool_enabled("set_storage") {
1197                    return tool_disabled("set_storage");
1198                }
1199                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1200                    StorageType::Session => "setSessionStorage",
1201                    StorageType::Local => "setLocalStorage",
1202                };
1203                let Some(key) = &params.key else {
1204                    return missing_param("key", "set");
1205                };
1206                let value = params
1207                    .value
1208                    .as_ref()
1209                    .cloned()
1210                    .unwrap_or(serde_json::Value::Null);
1211                let value_json =
1212                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1213                let code = format!(
1214                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1215                    js_string(key)
1216                );
1217                self.eval_bridge(&code, params.webview_label.as_deref())
1218                    .await
1219            }
1220            StorageAction::Delete => {
1221                if !self.state.privacy.is_tool_enabled("delete_storage") {
1222                    return tool_disabled("delete_storage");
1223                }
1224                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1225                    StorageType::Session => "deleteSessionStorage",
1226                    StorageType::Local => "deleteLocalStorage",
1227                };
1228                let Some(key) = &params.key else {
1229                    return missing_param("key", "delete");
1230                };
1231                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1232                self.eval_bridge(&code, params.webview_label.as_deref())
1233                    .await
1234            }
1235            StorageAction::GetCookies => {
1236                self.eval_bridge(
1237                    "return window.__VICTAURI__?.getCookies()",
1238                    params.webview_label.as_deref(),
1239                )
1240                .await
1241            }
1242        }
1243    }
1244
1245    #[tool(
1246        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.",
1247        annotations(
1248            read_only_hint = false,
1249            destructive_hint = false,
1250            idempotent_hint = false,
1251            open_world_hint = false
1252        )
1253    )]
1254    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1255        match params.action {
1256            NavigateAction::GoTo => {
1257                if !self.state.privacy.is_tool_enabled("navigate") {
1258                    return tool_disabled("navigate");
1259                }
1260                let Some(url) = &params.url else {
1261                    return missing_param("url", "go_to");
1262                };
1263                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1264                    return tool_error(e);
1265                }
1266                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1267                self.eval_bridge(&code, params.webview_label.as_deref())
1268                    .await
1269            }
1270            NavigateAction::GoBack => {
1271                self.eval_bridge(
1272                    "return window.__VICTAURI__?.navigateBack()",
1273                    params.webview_label.as_deref(),
1274                )
1275                .await
1276            }
1277            NavigateAction::GetHistory => {
1278                self.eval_bridge(
1279                    "return window.__VICTAURI__?.getNavigationLog()",
1280                    params.webview_label.as_deref(),
1281                )
1282                .await
1283            }
1284            NavigateAction::SetDialogResponse => {
1285                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1286                    return tool_disabled("set_dialog_response");
1287                }
1288                let Some(dialog_type) = params.dialog_type else {
1289                    return missing_param("dialog_type", "set_dialog_response");
1290                };
1291                let Some(dialog_action) = params.dialog_action else {
1292                    return missing_param("dialog_action", "set_dialog_response");
1293                };
1294                let text_arg = params
1295                    .text
1296                    .as_ref()
1297                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1298                let code = format!(
1299                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1300                    js_string(dialog_type.as_str()),
1301                    js_string(dialog_action.as_str())
1302                );
1303                self.eval_bridge(&code, params.webview_label.as_deref())
1304                    .await
1305            }
1306            NavigateAction::GetDialogLog => {
1307                self.eval_bridge(
1308                    "return window.__VICTAURI__?.getDialogLog()",
1309                    params.webview_label.as_deref(),
1310                )
1311                .await
1312            }
1313        }
1314    }
1315
1316    #[tool(
1317        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).",
1318        annotations(
1319            read_only_hint = false,
1320            destructive_hint = false,
1321            idempotent_hint = false,
1322            open_world_hint = false
1323        )
1324    )]
1325    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1326        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1327        self.track_tool_call();
1328        if !self.state.privacy.is_tool_enabled("recording") {
1329            return tool_disabled("recording");
1330        }
1331        match params.action {
1332            RecordingAction::Start => {
1333                let session_id = params
1334                    .session_id
1335                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1336                match self.state.recorder.start(session_id.clone()) {
1337                    Ok(()) => {
1338                        let result = serde_json::json!({
1339                            "started": true,
1340                            "session_id": session_id,
1341                        });
1342                        CallToolResult::success(vec![Content::text(result.to_string())])
1343                    }
1344                    Err(e) => tool_error(e.to_string()),
1345                }
1346            }
1347            RecordingAction::Stop => match self.state.recorder.stop() {
1348                Some(session) => json_result(&session),
1349                None => tool_error("no recording is active"),
1350            },
1351            RecordingAction::Checkpoint => {
1352                let Some(id) = params.checkpoint_id else {
1353                    return missing_param("checkpoint_id", "checkpoint");
1354                };
1355                let state = params.state.unwrap_or(serde_json::Value::Null);
1356                match self
1357                    .state
1358                    .recorder
1359                    .checkpoint(id.clone(), params.checkpoint_label, state)
1360                {
1361                    Ok(()) => {
1362                        let result = serde_json::json!({
1363                            "created": true,
1364                            "checkpoint_id": id,
1365                            "event_index": self.state.recorder.event_count(),
1366                        });
1367                        CallToolResult::success(vec![Content::text(result.to_string())])
1368                    }
1369                    Err(e) => tool_error(e.to_string()),
1370                }
1371            }
1372            RecordingAction::ListCheckpoints => {
1373                let checkpoints = self.state.recorder.get_checkpoints();
1374                json_result(&checkpoints)
1375            }
1376            RecordingAction::GetEvents => {
1377                let events = self
1378                    .state
1379                    .recorder
1380                    .events_since(params.since_index.unwrap_or(0));
1381                json_result(&events)
1382            }
1383            RecordingAction::EventsBetween => {
1384                let Some(from) = &params.from else {
1385                    return missing_param("from", "events_between");
1386                };
1387                let Some(to) = &params.to else {
1388                    return missing_param("to", "events_between");
1389                };
1390                match self.state.recorder.events_between_checkpoints(from, to) {
1391                    Ok(events) => json_result(&events),
1392                    Err(e) => tool_error(e.to_string()),
1393                }
1394            }
1395            RecordingAction::GetReplay => {
1396                let calls = self.state.recorder.ipc_replay_sequence();
1397                json_result(&calls)
1398            }
1399            RecordingAction::Export => match self.state.recorder.export() {
1400                Some(s) => {
1401                    let json = serde_json::to_string_pretty(&s)
1402                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1403                    CallToolResult::success(vec![Content::text(json)])
1404                }
1405                None => tool_error("no recording is active — start one first"),
1406            },
1407            RecordingAction::Import => {
1408                let Some(session_json) = &params.session_json else {
1409                    return missing_param("session_json", "import");
1410                };
1411                if session_json.len() > MAX_SESSION_JSON {
1412                    return tool_error("session JSON exceeds maximum size (10 MB)");
1413                }
1414                let session: victauri_core::RecordedSession =
1415                    match serde_json::from_str(session_json) {
1416                        Ok(s) => s,
1417                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1418                    };
1419
1420                let result = serde_json::json!({
1421                    "imported": true,
1422                    "session_id": session.id,
1423                    "event_count": session.events.len(),
1424                    "checkpoint_count": session.checkpoints.len(),
1425                    "started_at": session.started_at.to_rfc3339(),
1426                });
1427                self.state.recorder.import(session);
1428                CallToolResult::success(vec![Content::text(result.to_string())])
1429            }
1430        }
1431    }
1432
1433    #[tool(
1434        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).",
1435        annotations(
1436            read_only_hint = true,
1437            destructive_hint = false,
1438            idempotent_hint = true,
1439            open_world_hint = false
1440        )
1441    )]
1442    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1443        match params.action {
1444            InspectAction::GetStyles => {
1445                let Some(ref_id) = &params.ref_id else {
1446                    return missing_param("ref_id", "get_styles");
1447                };
1448                let props_arg = match &params.properties {
1449                    Some(props) => {
1450                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1451                        format!("[{}]", arr.join(","))
1452                    }
1453                    None => "null".to_string(),
1454                };
1455                let code = format!(
1456                    "return window.__VICTAURI__?.getStyles({}, {})",
1457                    js_string(ref_id),
1458                    props_arg
1459                );
1460                self.eval_bridge(&code, params.webview_label.as_deref())
1461                    .await
1462            }
1463            InspectAction::GetBoundingBoxes => {
1464                let Some(ref_ids) = &params.ref_ids else {
1465                    return missing_param("ref_ids", "get_bounding_boxes");
1466                };
1467                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1468                let code = format!(
1469                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1470                    refs.join(",")
1471                );
1472                self.eval_bridge(&code, params.webview_label.as_deref())
1473                    .await
1474            }
1475            InspectAction::Highlight => {
1476                let Some(ref_id) = &params.ref_id else {
1477                    return missing_param("ref_id", "highlight");
1478                };
1479                let color_arg = match &params.color {
1480                    Some(c) => match sanitize_css_color(c) {
1481                        Ok(safe) => format!("\"{safe}\""),
1482                        Err(e) => return tool_error(e),
1483                    },
1484                    None => "null".to_string(),
1485                };
1486                let label_arg = match &params.label {
1487                    Some(l) => js_string(l),
1488                    None => "null".to_string(),
1489                };
1490                let code = format!(
1491                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1492                    js_string(ref_id),
1493                    color_arg,
1494                    label_arg
1495                );
1496                self.eval_bridge(&code, params.webview_label.as_deref())
1497                    .await
1498            }
1499            InspectAction::ClearHighlights => {
1500                self.eval_bridge(
1501                    "return window.__VICTAURI__?.clearHighlights()",
1502                    params.webview_label.as_deref(),
1503                )
1504                .await
1505            }
1506            InspectAction::AuditAccessibility => {
1507                self.eval_bridge(
1508                    "return window.__VICTAURI__?.auditAccessibility()",
1509                    params.webview_label.as_deref(),
1510                )
1511                .await
1512            }
1513            InspectAction::GetPerformance => {
1514                self.eval_bridge(
1515                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1516                    params.webview_label.as_deref(),
1517                )
1518                .await
1519            }
1520        }
1521    }
1522
1523    #[tool(
1524        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1525        annotations(
1526            read_only_hint = false,
1527            destructive_hint = false,
1528            idempotent_hint = true,
1529            open_world_hint = false
1530        )
1531    )]
1532    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1533        match params.action {
1534            CssAction::Inject => {
1535                if !self.state.privacy.is_tool_enabled("inject_css") {
1536                    return tool_disabled("inject_css");
1537                }
1538                let Some(css) = &params.css else {
1539                    return missing_param("css", "inject");
1540                };
1541                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1542                self.eval_bridge(&code, params.webview_label.as_deref())
1543                    .await
1544            }
1545            CssAction::Remove => {
1546                if !self.state.privacy.is_tool_enabled("css.remove") {
1547                    return tool_disabled("css.remove");
1548                }
1549                self.eval_bridge(
1550                    "return window.__VICTAURI__?.removeInjectedCss()",
1551                    params.webview_label.as_deref(),
1552                )
1553                .await
1554            }
1555        }
1556    }
1557
1558    #[tool(
1559        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).",
1560        annotations(
1561            read_only_hint = true,
1562            destructive_hint = false,
1563            idempotent_hint = true,
1564            open_world_hint = false
1565        )
1566    )]
1567    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1568        match params.action {
1569            LogsAction::Console => {
1570                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1571                let code = if since_arg.is_empty() {
1572                    "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1573                } else {
1574                    format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1575                };
1576                self.eval_bridge(&code, params.webview_label.as_deref())
1577                    .await
1578            }
1579            LogsAction::Network => {
1580                let filter_arg = params
1581                    .filter
1582                    .as_ref()
1583                    .map_or_else(|| "null".to_string(), |f| js_string(f));
1584                let limit_arg = params
1585                    .limit
1586                    .map_or_else(|| "null".to_string(), |l| l.to_string());
1587                let code =
1588                    format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1589                self.eval_bridge(&code, params.webview_label.as_deref())
1590                    .await
1591            }
1592            LogsAction::Ipc => {
1593                let wait = params.wait_for_capture.unwrap_or(false);
1594                let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1595                if wait {
1596                    let limit_js = if limit_arg.is_empty() {
1597                        "undefined".to_string()
1598                    } else {
1599                        limit_arg.clone()
1600                    };
1601                    let code = format!(
1602                        r"return (async function() {{
1603                            await window.__VICTAURI__.waitForIpcComplete(500);
1604                            var log = window.__VICTAURI__.getIpcLog() || [];
1605                            var lim = {limit_js};
1606                            return (lim !== undefined) ? log.slice(-lim) : log;
1607                        }})()"
1608                    );
1609                    let timeout = std::time::Duration::from_millis(5000);
1610                    match self
1611                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1612                        .await
1613                    {
1614                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1615                        Err(e) => tool_error(e),
1616                    }
1617                } else {
1618                    let code = if limit_arg.is_empty() {
1619                        "return window.__VICTAURI__?.getIpcLog()".to_string()
1620                    } else {
1621                        format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1622                    };
1623                    self.eval_bridge(&code, params.webview_label.as_deref())
1624                        .await
1625                }
1626            }
1627            LogsAction::Navigation => {
1628                self.eval_bridge(
1629                    "return window.__VICTAURI__?.getNavigationLog()",
1630                    params.webview_label.as_deref(),
1631                )
1632                .await
1633            }
1634            LogsAction::Dialogs => {
1635                self.eval_bridge(
1636                    "return window.__VICTAURI__?.getDialogLog()",
1637                    params.webview_label.as_deref(),
1638                )
1639                .await
1640            }
1641            LogsAction::Events => {
1642                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1643                let code = if since_arg.is_empty() {
1644                    "return window.__VICTAURI__?.getEventStream()".to_string()
1645                } else {
1646                    format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1647                };
1648                self.eval_bridge(&code, params.webview_label.as_deref())
1649                    .await
1650            }
1651            LogsAction::SlowIpc => {
1652                let Some(threshold) = params.threshold_ms else {
1653                    return missing_param("threshold_ms", "slow_ipc");
1654                };
1655                let limit = params.limit.unwrap_or(20);
1656                let code = format!(
1657                    r"return (function() {{
1658                        var log = window.__VICTAURI__?.getIpcLog() || [];
1659                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1660                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1661                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1662                    }})()",
1663                );
1664                self.eval_bridge(&code, None).await
1665            }
1666        }
1667    }
1668}
1669
1670impl VictauriMcpHandler {
1671    /// Create a new handler backed by the given state and webview bridge.
1672    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1673        Self {
1674            state,
1675            bridge,
1676            subscriptions: Arc::new(Mutex::new(HashSet::new())),
1677            bridge_checked: Arc::new(AtomicBool::new(false)),
1678        }
1679    }
1680
1681    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
1682        self.state.privacy.is_tool_enabled(name)
1683    }
1684
1685    pub(crate) async fn execute_tool(
1686        &self,
1687        name: &str,
1688        args: serde_json::Value,
1689    ) -> Result<CallToolResult, rest::ToolCallError> {
1690        if !self.state.privacy.is_tool_enabled(name) {
1691            return Ok(tool_disabled(name));
1692        }
1693        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1694        let start = std::time::Instant::now();
1695        tracing::debug!(tool = %name, "REST tool invocation started");
1696
1697        let result = match name {
1698            "eval_js" => {
1699                let p: EvalJsParams = Self::parse_args(args)?;
1700                self.eval_js(Parameters(p)).await
1701            }
1702            "dom_snapshot" => {
1703                let p: SnapshotParams = Self::parse_args(args)?;
1704                self.dom_snapshot(Parameters(p)).await
1705            }
1706            "find_elements" => {
1707                let p: FindElementsParams = Self::parse_args(args)?;
1708                self.find_elements(Parameters(p)).await
1709            }
1710            "invoke_command" => {
1711                let p: InvokeCommandParams = Self::parse_args(args)?;
1712                self.invoke_command(Parameters(p)).await
1713            }
1714            "screenshot" => {
1715                let p: ScreenshotParams = Self::parse_args(args)?;
1716                self.screenshot(Parameters(p)).await
1717            }
1718            "verify_state" => {
1719                let p: VerifyStateParams = Self::parse_args(args)?;
1720                self.verify_state(Parameters(p)).await
1721            }
1722            "detect_ghost_commands" => {
1723                let p: GhostCommandParams = Self::parse_args(args)?;
1724                self.detect_ghost_commands(Parameters(p)).await
1725            }
1726            "check_ipc_integrity" => {
1727                let p: IpcIntegrityParams = Self::parse_args(args)?;
1728                self.check_ipc_integrity(Parameters(p)).await
1729            }
1730            "wait_for" => {
1731                let p: WaitForParams = Self::parse_args(args)?;
1732                self.wait_for(Parameters(p)).await
1733            }
1734            "assert_semantic" => {
1735                let p: SemanticAssertParams = Self::parse_args(args)?;
1736                self.assert_semantic(Parameters(p)).await
1737            }
1738            "resolve_command" => {
1739                let p: ResolveCommandParams = Self::parse_args(args)?;
1740                self.resolve_command(Parameters(p)).await
1741            }
1742            "get_registry" => {
1743                let p: RegistryParams = Self::parse_args(args)?;
1744                self.get_registry(Parameters(p)).await
1745            }
1746            "get_memory_stats" => self.get_memory_stats().await,
1747            "get_plugin_info" => self.get_plugin_info().await,
1748            "get_diagnostics" => {
1749                let p: DiagnosticsParams = Self::parse_args(args)?;
1750                self.get_diagnostics(Parameters(p)).await
1751            }
1752            "app_info" => self.app_info().await,
1753            "list_app_dir" => {
1754                let p: ListAppDirParams = Self::parse_args(args)?;
1755                self.list_app_dir(Parameters(p)).await
1756            }
1757            "read_app_file" => {
1758                let p: ReadAppFileParams = Self::parse_args(args)?;
1759                self.read_app_file(Parameters(p)).await
1760            }
1761            #[cfg(feature = "sqlite")]
1762            "query_db" => {
1763                let p: QueryDbParams = Self::parse_args(args)?;
1764                self.query_db(Parameters(p)).await
1765            }
1766            "interact" => {
1767                let p: InteractParams = Self::parse_args(args)?;
1768                self.interact(Parameters(p)).await
1769            }
1770            "input" => {
1771                let p: InputParams = Self::parse_args(args)?;
1772                self.input(Parameters(p)).await
1773            }
1774            "window" => {
1775                let p: WindowParams = Self::parse_args(args)?;
1776                self.window(Parameters(p)).await
1777            }
1778            "storage" => {
1779                let p: StorageParams = Self::parse_args(args)?;
1780                self.storage(Parameters(p)).await
1781            }
1782            "navigate" => {
1783                let p: NavigateParams = Self::parse_args(args)?;
1784                self.navigate(Parameters(p)).await
1785            }
1786            "recording" => {
1787                let p: RecordingParams = Self::parse_args(args)?;
1788                self.recording(Parameters(p)).await
1789            }
1790            "inspect" => {
1791                let p: InspectParams = Self::parse_args(args)?;
1792                self.inspect(Parameters(p)).await
1793            }
1794            "css" => {
1795                let p: CssParams = Self::parse_args(args)?;
1796                self.css(Parameters(p)).await
1797            }
1798            "logs" => {
1799                let p: LogsParams = Self::parse_args(args)?;
1800                self.logs(Parameters(p)).await
1801            }
1802            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
1803        };
1804
1805        let elapsed = start.elapsed();
1806        tracing::debug!(
1807            tool = %name,
1808            elapsed_ms = elapsed.as_millis() as u64,
1809            "REST tool invocation completed"
1810        );
1811
1812        if self.state.privacy.redaction_enabled {
1813            Ok(Self::redact_result(result, &self.state.privacy))
1814        } else {
1815            Ok(result)
1816        }
1817    }
1818
1819    fn parse_args<T: serde::de::DeserializeOwned>(
1820        args: serde_json::Value,
1821    ) -> Result<T, rest::ToolCallError> {
1822        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
1823    }
1824
1825    fn redact_result(
1826        mut result: CallToolResult,
1827        privacy: &crate::privacy::PrivacyConfig,
1828    ) -> CallToolResult {
1829        for item in &mut result.content {
1830            if let RawContent::Text(ref mut tc) = item.raw {
1831                tc.text = privacy.redact_output(&tc.text);
1832            }
1833        }
1834        result
1835    }
1836
1837    fn track_tool_call(&self) {
1838        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1839    }
1840
1841    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
1842        match dir.unwrap_or(AppDir::Data) {
1843            AppDir::Data => self.bridge.app_data_dir(),
1844            AppDir::Config => self.bridge.app_config_dir(),
1845            AppDir::Log => self.bridge.app_log_dir(),
1846            AppDir::LocalData => self.bridge.app_local_data_dir(),
1847        }
1848    }
1849
1850    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
1851        let canon_base = std::fs::canonicalize(base)
1852            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
1853        let canon_target = std::fs::canonicalize(target)
1854            .map_err(|e| format!("cannot resolve target path: {e}"))?;
1855        if !canon_target.starts_with(&canon_base) {
1856            return Err("path traversal not allowed".to_string());
1857        }
1858        Ok(())
1859    }
1860
1861    fn list_dir_recursive(
1862        dir: &std::path::Path,
1863        base: &std::path::Path,
1864        depth: u32,
1865        max_depth: u32,
1866        pattern: Option<&str>,
1867        entries: &mut Vec<serde_json::Value>,
1868    ) {
1869        let Ok(read_dir) = std::fs::read_dir(dir) else {
1870            return;
1871        };
1872        for entry in read_dir.flatten() {
1873            let path = entry.path();
1874            if path.is_symlink() {
1875                continue;
1876            }
1877            let name = entry.file_name().to_string_lossy().into_owned();
1878            let relative = path
1879                .strip_prefix(base)
1880                .unwrap_or(&path)
1881                .to_string_lossy()
1882                .into_owned();
1883
1884            if let Some(pat) = pattern
1885                && !Self::matches_glob(&name, pat)
1886                && !path.is_dir()
1887            {
1888                continue;
1889            }
1890
1891            let is_dir = path.is_dir();
1892            let meta = std::fs::metadata(&path).ok();
1893
1894            entries.push(serde_json::json!({
1895                "name": name,
1896                "path": relative,
1897                "is_dir": is_dir,
1898                "size": meta.as_ref().map(std::fs::Metadata::len),
1899                "modified": meta.as_ref()
1900                    .and_then(|m| m.modified().ok())
1901                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
1902                        .unwrap_or_default().as_secs()),
1903            }));
1904
1905            if is_dir && depth < max_depth {
1906                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
1907            }
1908        }
1909    }
1910
1911    fn matches_glob(name: &str, pattern: &str) -> bool {
1912        if pattern == "*" {
1913            return true;
1914        }
1915        if let Some(suffix) = pattern.strip_prefix("*.") {
1916            return name.ends_with(&format!(".{suffix}"));
1917        }
1918        if let Some(prefix) = pattern.strip_suffix("*") {
1919            return name.starts_with(prefix);
1920        }
1921        name == pattern
1922    }
1923
1924    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
1925        match self.eval_with_return(code, webview_label).await {
1926            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1927            Err(e) => tool_error(e),
1928        }
1929    }
1930
1931    async fn eval_with_return(
1932        &self,
1933        code: &str,
1934        webview_label: Option<&str>,
1935    ) -> Result<String, String> {
1936        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1937            .await
1938    }
1939
1940    async fn eval_with_return_timeout(
1941        &self,
1942        code: &str,
1943        webview_label: Option<&str>,
1944        timeout: std::time::Duration,
1945    ) -> Result<String, String> {
1946        self.track_tool_call();
1947        let id = uuid::Uuid::new_v4().to_string();
1948        let (tx, rx) = tokio::sync::oneshot::channel();
1949
1950        {
1951            let mut pending = self.state.pending_evals.lock().await;
1952            if pending.len() >= MAX_PENDING_EVALS {
1953                return Err(format!(
1954                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1955                ));
1956            }
1957            pending.insert(id.clone(), tx);
1958        }
1959
1960        // Auto-prepend `return` so bare expressions produce a value.
1961        // Only skip for code that starts with a statement keyword where
1962        // prepending `return` would be a syntax error.
1963        let code = code.trim();
1964        let needs_return = !code.starts_with("return ")
1965            && !code.starts_with("return;")
1966            && !code.starts_with('{')
1967            && !code.starts_with("if ")
1968            && !code.starts_with("if(")
1969            && !code.starts_with("for ")
1970            && !code.starts_with("for(")
1971            && !code.starts_with("while ")
1972            && !code.starts_with("while(")
1973            && !code.starts_with("switch ")
1974            && !code.starts_with("try ")
1975            && !code.starts_with("const ")
1976            && !code.starts_with("let ")
1977            && !code.starts_with("var ")
1978            && !code.starts_with("function ")
1979            && !code.starts_with("class ")
1980            && !code.starts_with("throw ");
1981        let code = if needs_return {
1982            format!("return {code}")
1983        } else {
1984            code.to_string()
1985        };
1986
1987        let id_js = js_string(&id);
1988        let inject = format!(
1989            r"
1990            (async () => {{
1991                try {{
1992                    const __result = await (async () => {{ {code} }})();
1993                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1994                        id: {id_js},
1995                        result: JSON.stringify(__result)
1996                    }});
1997                }} catch (e) {{
1998                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1999                        id: {id_js},
2000                        result: JSON.stringify({{ __error: e.message }})
2001                    }});
2002                }}
2003            }})();
2004            "
2005        );
2006
2007        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
2008            self.state.pending_evals.lock().await.remove(&id);
2009            return Err(format!("eval injection failed: {e}"));
2010        }
2011
2012        match tokio::time::timeout(timeout, rx).await {
2013            Ok(Ok(result)) => {
2014                self.check_bridge_version_once();
2015                Ok(result)
2016            }
2017            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
2018            Err(_) => {
2019                self.state.pending_evals.lock().await.remove(&id);
2020                Err(format!("eval timed out after {}s", timeout.as_secs()))
2021            }
2022        }
2023    }
2024
2025    fn check_bridge_version_once(&self) {
2026        if self.bridge_checked.swap(true, Ordering::Relaxed) {
2027            return;
2028        }
2029        let handler = self.clone();
2030        tokio::spawn(async move {
2031            match handler
2032                .eval_with_return_timeout(
2033                    "window.__VICTAURI__?.version",
2034                    None,
2035                    std::time::Duration::from_secs(5),
2036                )
2037                .await
2038            {
2039                Ok(v) => {
2040                    let v = v.trim_matches('"');
2041                    if v == BRIDGE_VERSION {
2042                        tracing::debug!("Bridge version verified: {v}");
2043                    } else {
2044                        tracing::warn!(
2045                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
2046                        );
2047                    }
2048                }
2049                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
2050            }
2051        });
2052    }
2053}
2054
2055const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection tool for Tauri applications. \
2056It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
2057(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
2058(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
2059\n\nBACKEND tools (direct Rust access, no webview needed): \
2060'app_info' (app config, directory paths, discovered databases, process info), \
2061'list_app_dir' (browse app data/config/log directories), \
2062'read_app_file' (read files from app directories), \
2063'query_db' (read-only SQLite queries with auto-discovery). \
2064\n\nWEBVIEW tools: \
2065'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
2066'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
2067'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
2068\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
2069\n\nCOMPOUND tools with an 'action' parameter: \
2070'window' (get_state, list, manage, resize, move_to, set_title), \
2071'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
2072set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
2073get_events, events_between, get_replay, export, import), \
2074'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
2075\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
2076get_memory_stats, get_plugin_info, get_diagnostics.";
2077
2078impl ServerHandler for VictauriMcpHandler {
2079    fn get_info(&self) -> ServerInfo {
2080        ServerInfo::new(
2081            ServerCapabilities::builder()
2082                .enable_tools()
2083                .enable_resources()
2084                .enable_resources_subscribe()
2085                .build(),
2086        )
2087        .with_instructions(SERVER_INSTRUCTIONS)
2088    }
2089
2090    async fn list_tools(
2091        &self,
2092        _request: Option<PaginatedRequestParams>,
2093        _context: RequestContext<RoleServer>,
2094    ) -> Result<ListToolsResult, ErrorData> {
2095        let all_tools = Self::tool_router().list_all();
2096        let filtered: Vec<Tool> = all_tools
2097            .into_iter()
2098            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
2099            .collect();
2100        Ok(ListToolsResult {
2101            tools: filtered,
2102            ..Default::default()
2103        })
2104    }
2105
2106    async fn call_tool(
2107        &self,
2108        request: CallToolRequestParams,
2109        context: RequestContext<RoleServer>,
2110    ) -> Result<CallToolResult, ErrorData> {
2111        let tool_name: String = request.name.as_ref().to_owned();
2112        if !self.state.privacy.is_tool_enabled(&tool_name) {
2113            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
2114            return Ok(tool_disabled(&tool_name));
2115        }
2116        self.state
2117            .tool_invocations
2118            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2119        let start = std::time::Instant::now();
2120        tracing::debug!(tool = %tool_name, "tool invocation started");
2121        let ctx = ToolCallContext::new(self, request, context);
2122        let result = Self::tool_router().call(ctx).await;
2123        let elapsed = start.elapsed();
2124        tracing::debug!(
2125            tool = %tool_name,
2126            elapsed_ms = elapsed.as_millis() as u64,
2127            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
2128            "tool invocation completed"
2129        );
2130
2131        // Centralized output redaction: apply to all text content so no
2132        // individual tool can accidentally leak secrets.
2133        if self.state.privacy.redaction_enabled {
2134            result.map(|mut r| {
2135                for item in &mut r.content {
2136                    if let RawContent::Text(ref mut tc) = item.raw {
2137                        tc.text = self.state.privacy.redact_output(&tc.text);
2138                    }
2139                }
2140                r
2141            })
2142        } else {
2143            result
2144        }
2145    }
2146
2147    fn get_tool(&self, name: &str) -> Option<Tool> {
2148        if !self.state.privacy.is_tool_enabled(name) {
2149            return None;
2150        }
2151        Self::tool_router().get(name).cloned()
2152    }
2153
2154    async fn list_resources(
2155        &self,
2156        _request: Option<PaginatedRequestParams>,
2157        _context: RequestContext<RoleServer>,
2158    ) -> Result<ListResourcesResult, ErrorData> {
2159        Ok(ListResourcesResult {
2160            resources: vec![
2161                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
2162                    .with_description(
2163                        "Live IPC call log — all commands invoked between frontend and backend",
2164                    )
2165                    .with_mime_type("application/json")
2166                    .no_annotation(),
2167                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
2168                    .with_description(
2169                        "Current state of all Tauri windows — position, size, visibility, focus",
2170                    )
2171                    .with_mime_type("application/json")
2172                    .no_annotation(),
2173                RawResource::new(RESOURCE_URI_STATE, "state")
2174                    .with_description(
2175                        "Victauri plugin state — event count, registered commands, memory stats",
2176                    )
2177                    .with_mime_type("application/json")
2178                    .no_annotation(),
2179            ],
2180            ..Default::default()
2181        })
2182    }
2183
2184    async fn read_resource(
2185        &self,
2186        request: ReadResourceRequestParams,
2187        _context: RequestContext<RoleServer>,
2188    ) -> Result<ReadResourceResult, ErrorData> {
2189        let uri = &request.uri;
2190        let json = match uri.as_str() {
2191            RESOURCE_URI_IPC_LOG => {
2192                if let Ok(json) = self
2193                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
2194                    .await
2195                {
2196                    json
2197                } else {
2198                    let calls = self.state.event_log.ipc_calls();
2199                    serde_json::to_string_pretty(&calls)
2200                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2201                }
2202            }
2203            RESOURCE_URI_WINDOWS => {
2204                let states = self.bridge.get_window_states(None);
2205                serde_json::to_string_pretty(&states)
2206                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2207            }
2208            RESOURCE_URI_STATE => {
2209                let state_json = serde_json::json!({
2210                    "events_captured": self.state.event_log.len(),
2211                    "commands_registered": self.state.registry.count(),
2212                    "memory": crate::memory::current_stats(),
2213                    "port": self.state.port.load(Ordering::Relaxed),
2214                });
2215                serde_json::to_string_pretty(&state_json)
2216                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2217            }
2218            _ => {
2219                return Err(ErrorData::resource_not_found(
2220                    format!("unknown resource: {uri}"),
2221                    None,
2222                ));
2223            }
2224        };
2225
2226        let json = if self.state.privacy.redaction_enabled {
2227            self.state.privacy.redact_output(&json)
2228        } else {
2229            json
2230        };
2231
2232        Ok(ReadResourceResult::new(vec![ResourceContents::text(
2233            json, uri,
2234        )]))
2235    }
2236
2237    async fn subscribe(
2238        &self,
2239        request: SubscribeRequestParams,
2240        _context: RequestContext<RoleServer>,
2241    ) -> Result<(), ErrorData> {
2242        let uri = &request.uri;
2243        match uri.as_str() {
2244            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
2245                self.subscriptions.lock().await.insert(uri.clone());
2246                tracing::info!("Client subscribed to resource: {uri}");
2247                Ok(())
2248            }
2249            _ => Err(ErrorData::resource_not_found(
2250                format!("unknown resource: {uri}"),
2251                None,
2252            )),
2253        }
2254    }
2255
2256    async fn unsubscribe(
2257        &self,
2258        request: UnsubscribeRequestParams,
2259        _context: RequestContext<RoleServer>,
2260    ) -> Result<(), ErrorData> {
2261        self.subscriptions.lock().await.remove(&request.uri);
2262        tracing::info!("Client unsubscribed from resource: {}", request.uri);
2263        Ok(())
2264    }
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269    use super::*;
2270
2271    #[test]
2272    fn js_string_simple() {
2273        assert_eq!(js_string("hello"), "\"hello\"");
2274    }
2275
2276    #[test]
2277    fn js_string_single_quotes() {
2278        let result = js_string("it's a test");
2279        assert!(result.contains("it's a test"));
2280    }
2281
2282    #[test]
2283    fn js_string_double_quotes() {
2284        let result = js_string(r#"say "hello""#);
2285        assert!(result.contains(r#"\""#));
2286    }
2287
2288    #[test]
2289    fn js_string_backslashes() {
2290        let result = js_string(r"path\to\file");
2291        assert!(result.contains(r"\\"));
2292    }
2293
2294    #[test]
2295    fn js_string_newlines_and_tabs() {
2296        let result = js_string("line1\nline2\ttab");
2297        assert!(result.contains(r"\n"));
2298        assert!(result.contains(r"\t"));
2299        assert!(!result.contains('\n'));
2300    }
2301
2302    #[test]
2303    fn js_string_null_bytes() {
2304        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
2305        let result = js_string(&input);
2306        // serde_json escapes null bytes as
2307        assert!(result.contains("\\u0000"));
2308        assert!(!result.contains('\0'));
2309    }
2310
2311    #[test]
2312    fn js_string_template_literal_injection() {
2313        let result = js_string("`${alert(1)}`");
2314        // Should not contain unescaped backticks that could break template literals
2315        // serde_json wraps in double quotes, so backticks are safe
2316        assert!(result.starts_with('"'));
2317        assert!(result.ends_with('"'));
2318    }
2319
2320    #[test]
2321    fn js_string_unicode_separators() {
2322        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
2323        // JSON strings per RFC 8259, and serde_json passes them through literally.
2324        // Since js_string is used inside JS double-quoted strings (not template
2325        // literals), they are safe in modern JS engines (ES2019+).
2326        let result = js_string("a\u{2028}b\u{2029}c");
2327        // Verify the string is valid JSON that round-trips correctly
2328        let decoded: String = serde_json::from_str(&result).unwrap();
2329        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
2330    }
2331
2332    #[test]
2333    fn js_string_empty() {
2334        assert_eq!(js_string(""), "\"\"");
2335    }
2336
2337    #[test]
2338    fn js_string_html_script_close() {
2339        // </script> in a JS string inside HTML could break out of script tags
2340        let result = js_string("</script><img onerror=alert(1)>");
2341        assert!(result.starts_with('"'));
2342        // The string is JSON-encoded; verify it round-trips safely
2343        let decoded: String = serde_json::from_str(&result).unwrap();
2344        assert_eq!(decoded, "</script><img onerror=alert(1)>");
2345    }
2346
2347    #[test]
2348    fn js_string_very_long() {
2349        let long = "a".repeat(100_000);
2350        let result = js_string(&long);
2351        assert!(result.len() >= 100_002); // quotes + content
2352    }
2353
2354    // ── URL validation tests ────────────────────────────────────────────────
2355
2356    #[test]
2357    fn url_allows_http() {
2358        assert!(validate_url("http://example.com", false).is_ok());
2359    }
2360
2361    #[test]
2362    fn url_allows_https() {
2363        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
2364    }
2365
2366    #[test]
2367    fn url_allows_http_localhost() {
2368        assert!(validate_url("http://localhost:3000", false).is_ok());
2369    }
2370
2371    #[test]
2372    fn url_blocks_file_by_default() {
2373        let err = validate_url("file:///etc/passwd", false).unwrap_err();
2374        assert!(err.contains("file"), "error should mention the file scheme");
2375    }
2376
2377    #[test]
2378    fn url_allows_file_when_opted_in() {
2379        assert!(validate_url("file:///tmp/test.html", true).is_ok());
2380    }
2381
2382    #[test]
2383    fn url_blocks_javascript() {
2384        assert!(validate_url("javascript:alert(1)", false).is_err());
2385    }
2386
2387    #[test]
2388    fn url_blocks_javascript_case_insensitive() {
2389        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
2390    }
2391
2392    #[test]
2393    fn url_blocks_data_scheme() {
2394        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
2395    }
2396
2397    #[test]
2398    fn url_blocks_vbscript() {
2399        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
2400    }
2401
2402    #[test]
2403    fn url_rejects_invalid() {
2404        assert!(validate_url("not a url at all", false).is_err());
2405    }
2406
2407    #[test]
2408    fn url_strips_control_chars() {
2409        // Control characters should be stripped, leaving a valid URL
2410        let input = format!("http://example{}com", '\0');
2411        assert!(validate_url(&input, false).is_ok());
2412    }
2413
2414    // ── CSS color sanitization tests ───────────────────────────────────────
2415
2416    #[test]
2417    fn css_color_valid_hex() {
2418        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
2419        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
2420        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
2421    }
2422
2423    #[test]
2424    fn css_color_valid_rgb() {
2425        assert_eq!(
2426            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
2427            "rgb(255, 0, 0)"
2428        );
2429        assert_eq!(
2430            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
2431            "rgba(0, 0, 0, 0.5)"
2432        );
2433    }
2434
2435    #[test]
2436    fn css_color_valid_named() {
2437        assert_eq!(sanitize_css_color("red").unwrap(), "red");
2438        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
2439    }
2440
2441    #[test]
2442    fn css_color_valid_hsl() {
2443        assert_eq!(
2444            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
2445            "hsl(120, 50%, 50%)"
2446        );
2447    }
2448
2449    #[test]
2450    fn css_color_rejects_too_long() {
2451        let long = "a".repeat(101);
2452        assert!(sanitize_css_color(&long).is_err());
2453    }
2454
2455    #[test]
2456    fn css_color_rejects_backslash_escapes() {
2457        assert!(sanitize_css_color(r"red\00").is_err());
2458        assert!(sanitize_css_color(r"\72\65\64").is_err());
2459    }
2460
2461    #[test]
2462    fn css_color_rejects_url_injection() {
2463        assert!(sanitize_css_color("url(http://evil.com)").is_err());
2464        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
2465    }
2466
2467    #[test]
2468    fn css_color_rejects_expression_injection() {
2469        assert!(sanitize_css_color("expression(alert(1))").is_err());
2470        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
2471    }
2472
2473    #[test]
2474    fn css_color_rejects_import() {
2475        assert!(sanitize_css_color("@import url(evil.css)").is_err());
2476    }
2477
2478    #[test]
2479    fn css_color_rejects_semicolons_and_braces() {
2480        assert!(sanitize_css_color("red; background: url(evil)").is_err());
2481        assert!(sanitize_css_color("red} body { color: blue").is_err());
2482    }
2483
2484    #[test]
2485    fn css_color_rejects_special_chars() {
2486        assert!(sanitize_css_color("red<script>").is_err());
2487        assert!(sanitize_css_color("red\"onload=alert").is_err());
2488        assert!(sanitize_css_color("red'onclick=alert").is_err());
2489    }
2490
2491    #[test]
2492    fn css_color_trims_whitespace() {
2493        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
2494    }
2495
2496    #[test]
2497    fn css_color_empty_string() {
2498        assert_eq!(sanitize_css_color("").unwrap(), "");
2499    }
2500}