Skip to main content

victauri_plugin/mcp/
mod.rs

1mod backend_params;
2mod compound_params;
3mod helpers;
4mod other_params;
5mod server;
6mod verification_params;
7mod webview_params;
8mod window_params;
9
10use std::collections::HashSet;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use rmcp::handler::server::tool::ToolCallContext;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::model::{
17    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
18    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
19    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
20    Tool, UnsubscribeRequestParams,
21};
22use rmcp::service::RequestContext;
23use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
24use tokio::sync::Mutex;
25
26use crate::VictauriState;
27use crate::bridge::WebviewBridge;
28
29use helpers::{
30    js_string, json_result, missing_param, sanitize_css_color, tool_disabled, tool_error,
31    validate_url,
32};
33
34pub use backend_params::*;
35pub use compound_params::*;
36pub use other_params::{
37    FindElementsParams, ResolveCommandParams, SemanticAssertParams, WaitCondition, WaitForParams,
38};
39pub use server::*;
40pub use verification_params::*;
41pub use webview_params::*;
42pub use window_params::*;
43
44// ── MCP Handler ──────────────────────────────────────────────────────────────
45
46/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
47/// growth of the `pending_evals` map if callbacks are never resolved.
48pub(crate) const MAX_PENDING_EVALS: usize = 100;
49
50const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
51const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
52const RESOURCE_URI_STATE: &str = "victauri://state";
53
54const BRIDGE_VERSION: &str = "0.3.0";
55
56/// MCP tool handler that dispatches tool calls to the webview bridge and state.
57#[derive(Clone)]
58pub struct VictauriMcpHandler {
59    state: Arc<VictauriState>,
60    bridge: Arc<dyn WebviewBridge>,
61    subscriptions: Arc<Mutex<HashSet<String>>>,
62    bridge_checked: Arc<AtomicBool>,
63}
64
65#[tool_router]
66impl VictauriMcpHandler {
67    // ── Standalone Tools ────────────────────────────────────────────────────
68
69    #[tool(
70        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
71        annotations(
72            read_only_hint = false,
73            destructive_hint = true,
74            idempotent_hint = false,
75            open_world_hint = false
76        )
77    )]
78    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
79        if !self.state.privacy.is_tool_enabled("eval_js") {
80            return tool_disabled("eval_js");
81        }
82        match self
83            .eval_with_return(&params.code, params.webview_label.as_deref())
84            .await
85        {
86            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
87            Err(e) => tool_error(e),
88        }
89    }
90
91    #[tool(
92        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).",
93        annotations(
94            read_only_hint = true,
95            destructive_hint = false,
96            idempotent_hint = true,
97            open_world_hint = false
98        )
99    )]
100    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
101        let format = params.format.unwrap_or(SnapshotFormat::Compact);
102        let format_str = match format {
103            SnapshotFormat::Compact => "compact",
104            SnapshotFormat::Json => "json",
105        };
106        let code = format!(
107            "return window.__VICTAURI__?.snapshot({})",
108            js_string(format_str)
109        );
110        self.eval_bridge(&code, params.webview_label.as_deref())
111            .await
112    }
113
114    #[tool(
115        description = "Search for elements by text, role, test_id, CSS selector, or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
116        annotations(
117            read_only_hint = true,
118            destructive_hint = false,
119            idempotent_hint = true,
120            open_world_hint = false
121        )
122    )]
123    async fn find_elements(
124        &self,
125        Parameters(params): Parameters<FindElementsParams>,
126    ) -> CallToolResult {
127        let mut parts: Vec<String> = Vec::new();
128        if let Some(t) = &params.text {
129            parts.push(format!("text: {}", js_string(t)));
130        }
131        if let Some(r) = &params.role {
132            parts.push(format!("role: {}", js_string(r)));
133        }
134        if let Some(tid) = &params.test_id {
135            parts.push(format!("test_id: {}", js_string(tid)));
136        }
137        if let Some(c) = &params.css {
138            parts.push(format!("css: {}", js_string(c)));
139        }
140        if let Some(n) = &params.name {
141            parts.push(format!("name: {}", js_string(n)));
142        }
143        if let Some(max) = params.max_results {
144            parts.push(format!("max_results: {max}"));
145        }
146        let code = format!(
147            "return window.__VICTAURI__?.findElements({{ {} }})",
148            parts.join(", ")
149        );
150        self.eval_bridge(&code, params.webview_label.as_deref())
151            .await
152    }
153
154    #[tool(
155        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.",
156        annotations(
157            read_only_hint = false,
158            destructive_hint = true,
159            idempotent_hint = false,
160            open_world_hint = false
161        )
162    )]
163    async fn invoke_command(
164        &self,
165        Parameters(params): Parameters<InvokeCommandParams>,
166    ) -> CallToolResult {
167        if !self.state.privacy.is_tool_enabled("invoke_command") {
168            return tool_disabled("invoke_command");
169        }
170        if !self.state.privacy.is_command_allowed(&params.command) {
171            return tool_error(format!(
172                "command '{}' is blocked by privacy configuration",
173                params.command
174            ));
175        }
176        let args_json = params.args.unwrap_or(serde_json::json!({}));
177        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
178        let code = format!(
179            "return window.__TAURI__.core.invoke({}, {args_str})",
180            js_string(&params.command)
181        );
182        match self
183            .eval_with_return(&code, params.webview_label.as_deref())
184            .await
185        {
186            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
187            Err(e) => tool_error(format!("invoke_command failed: {e}")),
188        }
189    }
190
191    #[tool(
192        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
193        annotations(
194            read_only_hint = true,
195            destructive_hint = false,
196            idempotent_hint = true,
197            open_world_hint = false
198        )
199    )]
200    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
201        self.track_tool_call();
202        if !self.state.privacy.is_tool_enabled("screenshot") {
203            return tool_disabled("screenshot");
204        }
205        match self
206            .bridge
207            .get_native_handle(params.window_label.as_deref())
208        {
209            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
210                Ok(png_bytes) => {
211                    use base64::Engine;
212                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
213                    CallToolResult::success(vec![Content::image(b64, "image/png")])
214                }
215                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
216            },
217            Err(e) => tool_error(format!("cannot get window handle: {e}")),
218        }
219    }
220
221    #[tool(
222        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
223        annotations(
224            read_only_hint = true,
225            destructive_hint = false,
226            idempotent_hint = true,
227            open_world_hint = false
228        )
229    )]
230    async fn verify_state(
231        &self,
232        Parameters(params): Parameters<VerifyStateParams>,
233    ) -> CallToolResult {
234        let code = format!("return ({})", params.frontend_expr);
235        let frontend_json = match self
236            .eval_with_return(&code, params.webview_label.as_deref())
237            .await
238        {
239            Ok(result) => result,
240            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
241        };
242
243        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
244            Ok(v) => v,
245            Err(e) => {
246                return tool_error(format!(
247                    "frontend expression did not return valid JSON: {e}"
248                ));
249            }
250        };
251
252        let result = victauri_core::verify_state(frontend_state, params.backend_state);
253        json_result(&result)
254    }
255
256    #[tool(
257        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.",
258        annotations(
259            read_only_hint = true,
260            destructive_hint = false,
261            idempotent_hint = true,
262            open_world_hint = false
263        )
264    )]
265    async fn detect_ghost_commands(
266        &self,
267        Parameters(params): Parameters<GhostCommandParams>,
268    ) -> CallToolResult {
269        let code = "return window.__VICTAURI__?.getIpcLog()";
270        let ipc_json = match self
271            .eval_with_return(code, params.webview_label.as_deref())
272            .await
273        {
274            Ok(r) => r,
275            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
276        };
277
278        let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
279            Ok(v) => v,
280            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
281        };
282        let frontend_commands: Vec<String> = ipc_calls
283            .iter()
284            .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
285            .collect::<std::collections::HashSet<_>>()
286            .into_iter()
287            .collect();
288
289        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
290        json_result(&report)
291    }
292
293    #[tool(
294        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
295        annotations(
296            read_only_hint = true,
297            destructive_hint = false,
298            idempotent_hint = true,
299            open_world_hint = false
300        )
301    )]
302    async fn check_ipc_integrity(
303        &self,
304        Parameters(params): Parameters<IpcIntegrityParams>,
305    ) -> CallToolResult {
306        let threshold = params.stale_threshold_ms.unwrap_or(5000);
307        let code = format!(
308            r"return (function() {{
309                var log = window.__VICTAURI__?.getIpcLog() || [];
310                var now = Date.now();
311                var threshold = {threshold};
312                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
313                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
314                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
315                return {{
316                    healthy: stale.length === 0 && errored.length === 0,
317                    total_calls: log.length,
318                    pending_count: pending.length,
319                    stale_count: stale.length,
320                    error_count: errored.length,
321                    stale_calls: stale.slice(0, 20),
322                    errored_calls: errored.slice(0, 20)
323                }};
324            }})()"
325        );
326        self.eval_bridge(&code, params.webview_label.as_deref())
327            .await
328    }
329
330    #[tool(
331        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).",
332        annotations(
333            read_only_hint = true,
334            destructive_hint = false,
335            idempotent_hint = true,
336            open_world_hint = false
337        )
338    )]
339    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
340        let value = params
341            .value
342            .as_ref()
343            .map_or_else(|| "null".to_string(), |v| js_string(v));
344        let timeout_ms = params.timeout_ms.unwrap_or(10000);
345        let poll = params.poll_ms.unwrap_or(200);
346        let code = format!(
347            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
348            js_string(params.condition.as_str())
349        );
350        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
351        match self
352            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
353            .await
354        {
355            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
356            Err(e) => tool_error(e),
357        }
358    }
359
360    #[tool(
361        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.",
362        annotations(
363            read_only_hint = true,
364            destructive_hint = false,
365            idempotent_hint = true,
366            open_world_hint = false
367        )
368    )]
369    async fn assert_semantic(
370        &self,
371        Parameters(params): Parameters<SemanticAssertParams>,
372    ) -> CallToolResult {
373        let code = format!("return ({})", params.expression);
374        let actual_json = match self
375            .eval_with_return(&code, params.webview_label.as_deref())
376            .await
377        {
378            Ok(result) => result,
379            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
380        };
381
382        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
383            Ok(v) => v,
384            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
385        };
386
387        let assertion = victauri_core::SemanticAssertion {
388            label: params.label,
389            condition: params.condition,
390            expected: params.expected,
391        };
392
393        let result = victauri_core::evaluate_assertion(actual, &assertion);
394        json_result(&result)
395    }
396
397    #[tool(
398        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
399        annotations(
400            read_only_hint = true,
401            destructive_hint = false,
402            idempotent_hint = true,
403            open_world_hint = false
404        )
405    )]
406    async fn resolve_command(
407        &self,
408        Parameters(params): Parameters<ResolveCommandParams>,
409    ) -> CallToolResult {
410        self.track_tool_call();
411        let limit = params.limit.unwrap_or(5);
412        let mut results = self.state.registry.resolve(&params.query);
413        results.truncate(limit);
414        json_result(&results)
415    }
416
417    #[tool(
418        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.",
419        annotations(
420            read_only_hint = true,
421            destructive_hint = false,
422            idempotent_hint = true,
423            open_world_hint = false
424        )
425    )]
426    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
427        self.track_tool_call();
428        let commands = match params.query {
429            Some(q) => self.state.registry.search(&q),
430            None => self.state.registry.list(),
431        };
432        json_result(&commands)
433    }
434
435    #[tool(
436        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.",
437        annotations(
438            read_only_hint = true,
439            destructive_hint = false,
440            idempotent_hint = true,
441            open_world_hint = false
442        )
443    )]
444    async fn get_memory_stats(&self) -> CallToolResult {
445        self.track_tool_call();
446        let stats = crate::memory::current_stats();
447        json_result(&stats)
448    }
449
450    #[tool(
451        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.",
452        annotations(
453            read_only_hint = true,
454            destructive_hint = false,
455            idempotent_hint = true,
456            open_world_hint = false
457        )
458    )]
459    async fn get_plugin_info(&self) -> CallToolResult {
460        self.track_tool_call();
461        let disabled: Vec<&str> = self
462            .state
463            .privacy
464            .disabled_tools
465            .iter()
466            .map(std::string::String::as_str)
467            .collect();
468        let blocklist: Vec<&str> = self
469            .state
470            .privacy
471            .command_blocklist
472            .iter()
473            .map(std::string::String::as_str)
474            .collect();
475        let allowlist: Option<Vec<&str>> = self
476            .state
477            .privacy
478            .command_allowlist
479            .as_ref()
480            .map(|s| s.iter().map(std::string::String::as_str).collect());
481        let all_tools = Self::tool_router().list_all();
482        let enabled_tools: Vec<&str> = all_tools
483            .iter()
484            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
485            .map(|t| t.name.as_ref())
486            .collect();
487
488        let result = serde_json::json!({
489            "version": env!("CARGO_PKG_VERSION"),
490            "bridge_version": BRIDGE_VERSION,
491            "port": self.state.port.load(Ordering::Relaxed),
492            "tools": {
493                "total": all_tools.len(),
494                "enabled": enabled_tools.len(),
495                "enabled_list": enabled_tools,
496                "disabled_list": disabled,
497            },
498            "commands": {
499                "allowlist": allowlist,
500                "blocklist": blocklist,
501            },
502            "privacy": {
503                "profile": self.state.privacy.profile.to_string(),
504                "redaction_enabled": self.state.privacy.redaction_enabled,
505            },
506            "capacities": {
507                "event_log": self.state.event_log.capacity(),
508                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
509            },
510            "registered_commands": self.state.registry.count(),
511            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
512            "uptime_secs": self.state.started_at.elapsed().as_secs(),
513        });
514        json_result(&result)
515    }
516
517    // ── Compound Tools ──────────────────────────────────────────────────────
518
519    #[tool(
520        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.",
521        annotations(
522            read_only_hint = false,
523            destructive_hint = false,
524            idempotent_hint = false,
525            open_world_hint = false
526        )
527    )]
528    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
529        if !self.state.privacy.is_tool_enabled("interact") {
530            return tool_disabled("interact");
531        }
532        match params.action {
533            InteractAction::Click => {
534                let Some(ref_id) = &params.ref_id else {
535                    return missing_param("ref_id", "click");
536                };
537                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
538                self.eval_bridge(&code, params.webview_label.as_deref())
539                    .await
540            }
541            InteractAction::DoubleClick => {
542                let Some(ref_id) = &params.ref_id else {
543                    return missing_param("ref_id", "double_click");
544                };
545                let code = format!(
546                    "return window.__VICTAURI__?.doubleClick({})",
547                    js_string(ref_id)
548                );
549                self.eval_bridge(&code, params.webview_label.as_deref())
550                    .await
551            }
552            InteractAction::Hover => {
553                let Some(ref_id) = &params.ref_id else {
554                    return missing_param("ref_id", "hover");
555                };
556                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
557                self.eval_bridge(&code, params.webview_label.as_deref())
558                    .await
559            }
560            InteractAction::Focus => {
561                let Some(ref_id) = &params.ref_id else {
562                    return missing_param("ref_id", "focus");
563                };
564                let code = format!(
565                    "return window.__VICTAURI__?.focusElement({})",
566                    js_string(ref_id)
567                );
568                self.eval_bridge(&code, params.webview_label.as_deref())
569                    .await
570            }
571            InteractAction::ScrollIntoView => {
572                let ref_arg = params
573                    .ref_id
574                    .as_ref()
575                    .map_or_else(|| "null".to_string(), |r| js_string(r));
576                let x = params.x.unwrap_or(0.0);
577                let y = params.y.unwrap_or(0.0);
578                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
579                self.eval_bridge(&code, params.webview_label.as_deref())
580                    .await
581            }
582            InteractAction::SelectOption => {
583                let Some(ref_id) = &params.ref_id else {
584                    return missing_param("ref_id", "select_option");
585                };
586                let values = params.values.as_deref().unwrap_or(&[]);
587                let values_json =
588                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
589                let code = format!(
590                    "return window.__VICTAURI__?.selectOption({}, {})",
591                    js_string(ref_id),
592                    values_json
593                );
594                self.eval_bridge(&code, params.webview_label.as_deref())
595                    .await
596            }
597        }
598    }
599
600    #[tool(
601        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.",
602        annotations(
603            read_only_hint = false,
604            destructive_hint = false,
605            idempotent_hint = false,
606            open_world_hint = false
607        )
608    )]
609    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
610        match params.action {
611            InputAction::Fill => {
612                if !self.state.privacy.is_tool_enabled("fill") {
613                    return tool_disabled("fill");
614                }
615                let Some(ref_id) = &params.ref_id else {
616                    return missing_param("ref_id", "fill");
617                };
618                let Some(value) = &params.value else {
619                    return missing_param("value", "fill");
620                };
621                let code = format!(
622                    "return window.__VICTAURI__?.fill({}, {})",
623                    js_string(ref_id),
624                    js_string(value)
625                );
626                self.eval_bridge(&code, params.webview_label.as_deref())
627                    .await
628            }
629            InputAction::TypeText => {
630                if !self.state.privacy.is_tool_enabled("type_text") {
631                    return tool_disabled("type_text");
632                }
633                let Some(ref_id) = &params.ref_id else {
634                    return missing_param("ref_id", "type_text");
635                };
636                let Some(text) = &params.text else {
637                    return missing_param("text", "type_text");
638                };
639                let code = format!(
640                    "return window.__VICTAURI__?.type({}, {})",
641                    js_string(ref_id),
642                    js_string(text)
643                );
644                self.eval_bridge(&code, params.webview_label.as_deref())
645                    .await
646            }
647            InputAction::PressKey => {
648                let Some(key) = &params.key else {
649                    return missing_param("key", "press_key");
650                };
651                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
652                self.eval_bridge(&code, params.webview_label.as_deref())
653                    .await
654            }
655        }
656    }
657
658    #[tool(
659        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.",
660        annotations(
661            read_only_hint = false,
662            destructive_hint = false,
663            idempotent_hint = true,
664            open_world_hint = false
665        )
666    )]
667    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
668        self.track_tool_call();
669        match params.action {
670            WindowAction::GetState => {
671                let states = self.bridge.get_window_states(params.label.as_deref());
672                json_result(&states)
673            }
674            WindowAction::List => {
675                let labels = self.bridge.list_window_labels();
676                json_result(&labels)
677            }
678            WindowAction::Manage => {
679                if !self.state.privacy.is_tool_enabled("window.manage") {
680                    return tool_disabled("window.manage");
681                }
682                let Some(manage_action) = &params.manage_action else {
683                    return missing_param("manage_action", "manage");
684                };
685                match self
686                    .bridge
687                    .manage_window(params.label.as_deref(), manage_action.as_str())
688                {
689                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
690                    Err(e) => tool_error(e),
691                }
692            }
693            WindowAction::Resize => {
694                if !self.state.privacy.is_tool_enabled("window.resize") {
695                    return tool_disabled("window.resize");
696                }
697                let Some(width) = params.width else {
698                    return missing_param("width", "resize");
699                };
700                let Some(height) = params.height else {
701                    return missing_param("height", "resize");
702                };
703                match self
704                    .bridge
705                    .resize_window(params.label.as_deref(), width, height)
706                {
707                    Ok(()) => {
708                        let result =
709                            serde_json::json!({"ok": true, "width": width, "height": height});
710                        CallToolResult::success(vec![Content::text(result.to_string())])
711                    }
712                    Err(e) => tool_error(e),
713                }
714            }
715            WindowAction::MoveTo => {
716                if !self.state.privacy.is_tool_enabled("window.move_to") {
717                    return tool_disabled("window.move_to");
718                }
719                let Some(x) = params.x else {
720                    return missing_param("x", "move_to");
721                };
722                let Some(y) = params.y else {
723                    return missing_param("y", "move_to");
724                };
725                match self.bridge.move_window(params.label.as_deref(), x, y) {
726                    Ok(()) => {
727                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
728                        CallToolResult::success(vec![Content::text(result.to_string())])
729                    }
730                    Err(e) => tool_error(e),
731                }
732            }
733            WindowAction::SetTitle => {
734                if !self.state.privacy.is_tool_enabled("window.set_title") {
735                    return tool_disabled("window.set_title");
736                }
737                let Some(title) = &params.title else {
738                    return missing_param("title", "set_title");
739                };
740                match self.bridge.set_window_title(params.label.as_deref(), title) {
741                    Ok(()) => {
742                        let result = serde_json::json!({"ok": true, "title": title});
743                        CallToolResult::success(vec![Content::text(result.to_string())])
744                    }
745                    Err(e) => tool_error(e),
746                }
747            }
748        }
749    }
750
751    #[tool(
752        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
753        annotations(
754            read_only_hint = false,
755            destructive_hint = true,
756            idempotent_hint = false,
757            open_world_hint = false
758        )
759    )]
760    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
761        match params.action {
762            StorageAction::Get => {
763                let method = match params.storage_type.unwrap_or(StorageType::Local) {
764                    StorageType::Session => "getSessionStorage",
765                    StorageType::Local => "getLocalStorage",
766                };
767                let key_arg = params
768                    .key
769                    .as_ref()
770                    .map(|k| js_string(k))
771                    .unwrap_or_default();
772                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
773                self.eval_bridge(&code, params.webview_label.as_deref())
774                    .await
775            }
776            StorageAction::Set => {
777                if !self.state.privacy.is_tool_enabled("set_storage") {
778                    return tool_disabled("set_storage");
779                }
780                let method = match params.storage_type.unwrap_or(StorageType::Local) {
781                    StorageType::Session => "setSessionStorage",
782                    StorageType::Local => "setLocalStorage",
783                };
784                let Some(key) = &params.key else {
785                    return missing_param("key", "set");
786                };
787                let value = params
788                    .value
789                    .as_ref()
790                    .cloned()
791                    .unwrap_or(serde_json::Value::Null);
792                let value_json =
793                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
794                let code = format!(
795                    "return window.__VICTAURI__?.{method}({}, {value_json})",
796                    js_string(key)
797                );
798                self.eval_bridge(&code, params.webview_label.as_deref())
799                    .await
800            }
801            StorageAction::Delete => {
802                if !self.state.privacy.is_tool_enabled("delete_storage") {
803                    return tool_disabled("delete_storage");
804                }
805                let method = match params.storage_type.unwrap_or(StorageType::Local) {
806                    StorageType::Session => "deleteSessionStorage",
807                    StorageType::Local => "deleteLocalStorage",
808                };
809                let Some(key) = &params.key else {
810                    return missing_param("key", "delete");
811                };
812                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
813                self.eval_bridge(&code, params.webview_label.as_deref())
814                    .await
815            }
816            StorageAction::GetCookies => {
817                self.eval_bridge(
818                    "return window.__VICTAURI__?.getCookies()",
819                    params.webview_label.as_deref(),
820                )
821                .await
822            }
823        }
824    }
825
826    #[tool(
827        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.",
828        annotations(
829            read_only_hint = false,
830            destructive_hint = false,
831            idempotent_hint = false,
832            open_world_hint = false
833        )
834    )]
835    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
836        match params.action {
837            NavigateAction::GoTo => {
838                if !self.state.privacy.is_tool_enabled("navigate") {
839                    return tool_disabled("navigate");
840                }
841                let Some(url) = &params.url else {
842                    return missing_param("url", "go_to");
843                };
844                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
845                    return tool_error(e);
846                }
847                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
848                self.eval_bridge(&code, params.webview_label.as_deref())
849                    .await
850            }
851            NavigateAction::GoBack => {
852                self.eval_bridge(
853                    "return window.__VICTAURI__?.navigateBack()",
854                    params.webview_label.as_deref(),
855                )
856                .await
857            }
858            NavigateAction::GetHistory => {
859                self.eval_bridge(
860                    "return window.__VICTAURI__?.getNavigationLog()",
861                    params.webview_label.as_deref(),
862                )
863                .await
864            }
865            NavigateAction::SetDialogResponse => {
866                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
867                    return tool_disabled("set_dialog_response");
868                }
869                let Some(dialog_type) = params.dialog_type else {
870                    return missing_param("dialog_type", "set_dialog_response");
871                };
872                let Some(dialog_action) = params.dialog_action else {
873                    return missing_param("dialog_action", "set_dialog_response");
874                };
875                let text_arg = params
876                    .text
877                    .as_ref()
878                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
879                let code = format!(
880                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
881                    js_string(dialog_type.as_str()),
882                    js_string(dialog_action.as_str())
883                );
884                self.eval_bridge(&code, params.webview_label.as_deref())
885                    .await
886            }
887            NavigateAction::GetDialogLog => {
888                self.eval_bridge(
889                    "return window.__VICTAURI__?.getDialogLog()",
890                    params.webview_label.as_deref(),
891                )
892                .await
893            }
894        }
895    }
896
897    #[tool(
898        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).",
899        annotations(
900            read_only_hint = false,
901            destructive_hint = false,
902            idempotent_hint = false,
903            open_world_hint = false
904        )
905    )]
906    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
907        self.track_tool_call();
908        if !self.state.privacy.is_tool_enabled("recording") {
909            return tool_disabled("recording");
910        }
911        match params.action {
912            RecordingAction::Start => {
913                let session_id = params
914                    .session_id
915                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
916                match self.state.recorder.start(session_id.clone()) {
917                    Ok(()) => {
918                        let result = serde_json::json!({
919                            "started": true,
920                            "session_id": session_id,
921                        });
922                        CallToolResult::success(vec![Content::text(result.to_string())])
923                    }
924                    Err(e) => tool_error(e.to_string()),
925                }
926            }
927            RecordingAction::Stop => match self.state.recorder.stop() {
928                Some(session) => json_result(&session),
929                None => tool_error("no recording is active"),
930            },
931            RecordingAction::Checkpoint => {
932                let Some(id) = params.checkpoint_id else {
933                    return missing_param("checkpoint_id", "checkpoint");
934                };
935                let state = params.state.unwrap_or(serde_json::Value::Null);
936                match self
937                    .state
938                    .recorder
939                    .checkpoint(id.clone(), params.checkpoint_label, state)
940                {
941                    Ok(()) => {
942                        let result = serde_json::json!({
943                            "created": true,
944                            "checkpoint_id": id,
945                            "event_index": self.state.recorder.event_count(),
946                        });
947                        CallToolResult::success(vec![Content::text(result.to_string())])
948                    }
949                    Err(e) => tool_error(e.to_string()),
950                }
951            }
952            RecordingAction::ListCheckpoints => {
953                let checkpoints = self.state.recorder.get_checkpoints();
954                json_result(&checkpoints)
955            }
956            RecordingAction::GetEvents => {
957                let events = self
958                    .state
959                    .recorder
960                    .events_since(params.since_index.unwrap_or(0));
961                json_result(&events)
962            }
963            RecordingAction::EventsBetween => {
964                let Some(from) = &params.from else {
965                    return missing_param("from", "events_between");
966                };
967                let Some(to) = &params.to else {
968                    return missing_param("to", "events_between");
969                };
970                match self.state.recorder.events_between_checkpoints(from, to) {
971                    Ok(events) => json_result(&events),
972                    Err(e) => tool_error(e.to_string()),
973                }
974            }
975            RecordingAction::GetReplay => {
976                let calls = self.state.recorder.ipc_replay_sequence();
977                json_result(&calls)
978            }
979            RecordingAction::Export => match self.state.recorder.export() {
980                Some(s) => {
981                    let json = serde_json::to_string_pretty(&s)
982                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
983                    CallToolResult::success(vec![Content::text(json)])
984                }
985                None => tool_error("no recording is active — start one first"),
986            },
987            RecordingAction::Import => {
988                let Some(session_json) = &params.session_json else {
989                    return missing_param("session_json", "import");
990                };
991                let session: victauri_core::RecordedSession =
992                    match serde_json::from_str(session_json) {
993                        Ok(s) => s,
994                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
995                    };
996
997                let result = serde_json::json!({
998                    "imported": true,
999                    "session_id": session.id,
1000                    "event_count": session.events.len(),
1001                    "checkpoint_count": session.checkpoints.len(),
1002                    "started_at": session.started_at.to_rfc3339(),
1003                });
1004                self.state.recorder.import(session);
1005                CallToolResult::success(vec![Content::text(result.to_string())])
1006            }
1007        }
1008    }
1009
1010    #[tool(
1011        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).",
1012        annotations(
1013            read_only_hint = true,
1014            destructive_hint = false,
1015            idempotent_hint = true,
1016            open_world_hint = false
1017        )
1018    )]
1019    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1020        match params.action {
1021            InspectAction::GetStyles => {
1022                let Some(ref_id) = &params.ref_id else {
1023                    return missing_param("ref_id", "get_styles");
1024                };
1025                let props_arg = match &params.properties {
1026                    Some(props) => {
1027                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1028                        format!("[{}]", arr.join(","))
1029                    }
1030                    None => "null".to_string(),
1031                };
1032                let code = format!(
1033                    "return window.__VICTAURI__?.getStyles({}, {})",
1034                    js_string(ref_id),
1035                    props_arg
1036                );
1037                self.eval_bridge(&code, params.webview_label.as_deref())
1038                    .await
1039            }
1040            InspectAction::GetBoundingBoxes => {
1041                let Some(ref_ids) = &params.ref_ids else {
1042                    return missing_param("ref_ids", "get_bounding_boxes");
1043                };
1044                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1045                let code = format!(
1046                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1047                    refs.join(",")
1048                );
1049                self.eval_bridge(&code, params.webview_label.as_deref())
1050                    .await
1051            }
1052            InspectAction::Highlight => {
1053                let Some(ref_id) = &params.ref_id else {
1054                    return missing_param("ref_id", "highlight");
1055                };
1056                let color_arg = match &params.color {
1057                    Some(c) => match sanitize_css_color(c) {
1058                        Ok(safe) => format!("\"{safe}\""),
1059                        Err(e) => return tool_error(e),
1060                    },
1061                    None => "null".to_string(),
1062                };
1063                let label_arg = match &params.label {
1064                    Some(l) => js_string(l),
1065                    None => "null".to_string(),
1066                };
1067                let code = format!(
1068                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1069                    js_string(ref_id),
1070                    color_arg,
1071                    label_arg
1072                );
1073                self.eval_bridge(&code, params.webview_label.as_deref())
1074                    .await
1075            }
1076            InspectAction::ClearHighlights => {
1077                self.eval_bridge(
1078                    "return window.__VICTAURI__?.clearHighlights()",
1079                    params.webview_label.as_deref(),
1080                )
1081                .await
1082            }
1083            InspectAction::AuditAccessibility => {
1084                self.eval_bridge(
1085                    "return window.__VICTAURI__?.auditAccessibility()",
1086                    params.webview_label.as_deref(),
1087                )
1088                .await
1089            }
1090            InspectAction::GetPerformance => {
1091                self.eval_bridge(
1092                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1093                    params.webview_label.as_deref(),
1094                )
1095                .await
1096            }
1097        }
1098    }
1099
1100    #[tool(
1101        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1102        annotations(
1103            read_only_hint = false,
1104            destructive_hint = false,
1105            idempotent_hint = true,
1106            open_world_hint = false
1107        )
1108    )]
1109    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1110        match params.action {
1111            CssAction::Inject => {
1112                if !self.state.privacy.is_tool_enabled("inject_css") {
1113                    return tool_disabled("inject_css");
1114                }
1115                let Some(css) = &params.css else {
1116                    return missing_param("css", "inject");
1117                };
1118                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1119                self.eval_bridge(&code, params.webview_label.as_deref())
1120                    .await
1121            }
1122            CssAction::Remove => {
1123                self.eval_bridge(
1124                    "return window.__VICTAURI__?.removeInjectedCss()",
1125                    params.webview_label.as_deref(),
1126                )
1127                .await
1128            }
1129        }
1130    }
1131
1132    #[tool(
1133        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).",
1134        annotations(
1135            read_only_hint = true,
1136            destructive_hint = false,
1137            idempotent_hint = true,
1138            open_world_hint = false
1139        )
1140    )]
1141    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1142        match params.action {
1143            LogsAction::Console => {
1144                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1145                let code = if since_arg.is_empty() {
1146                    "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1147                } else {
1148                    format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1149                };
1150                self.eval_bridge(&code, params.webview_label.as_deref())
1151                    .await
1152            }
1153            LogsAction::Network => {
1154                let filter_arg = params
1155                    .filter
1156                    .as_ref()
1157                    .map_or_else(|| "null".to_string(), |f| js_string(f));
1158                let limit_arg = params
1159                    .limit
1160                    .map_or_else(|| "null".to_string(), |l| l.to_string());
1161                let code =
1162                    format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1163                self.eval_bridge(&code, params.webview_label.as_deref())
1164                    .await
1165            }
1166            LogsAction::Ipc => {
1167                let wait = params.wait_for_capture.unwrap_or(false);
1168                let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1169                if wait {
1170                    let limit_js = if limit_arg.is_empty() {
1171                        "undefined".to_string()
1172                    } else {
1173                        limit_arg.clone()
1174                    };
1175                    let code = format!(
1176                        r"return (async function() {{
1177                            await window.__VICTAURI__.waitForIpcComplete(500);
1178                            var log = window.__VICTAURI__.getIpcLog() || [];
1179                            var lim = {limit_js};
1180                            return (lim !== undefined) ? log.slice(-lim) : log;
1181                        }})()"
1182                    );
1183                    let timeout = std::time::Duration::from_millis(5000);
1184                    match self
1185                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1186                        .await
1187                    {
1188                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1189                        Err(e) => tool_error(e),
1190                    }
1191                } else {
1192                    let code = if limit_arg.is_empty() {
1193                        "return window.__VICTAURI__?.getIpcLog()".to_string()
1194                    } else {
1195                        format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1196                    };
1197                    self.eval_bridge(&code, params.webview_label.as_deref())
1198                        .await
1199                }
1200            }
1201            LogsAction::Navigation => {
1202                self.eval_bridge(
1203                    "return window.__VICTAURI__?.getNavigationLog()",
1204                    params.webview_label.as_deref(),
1205                )
1206                .await
1207            }
1208            LogsAction::Dialogs => {
1209                self.eval_bridge(
1210                    "return window.__VICTAURI__?.getDialogLog()",
1211                    params.webview_label.as_deref(),
1212                )
1213                .await
1214            }
1215            LogsAction::Events => {
1216                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1217                let code = if since_arg.is_empty() {
1218                    "return window.__VICTAURI__?.getEventStream()".to_string()
1219                } else {
1220                    format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1221                };
1222                self.eval_bridge(&code, params.webview_label.as_deref())
1223                    .await
1224            }
1225            LogsAction::SlowIpc => {
1226                let Some(threshold) = params.threshold_ms else {
1227                    return missing_param("threshold_ms", "slow_ipc");
1228                };
1229                let limit = params.limit.unwrap_or(20);
1230                let code = format!(
1231                    r"return (function() {{
1232                        var log = window.__VICTAURI__?.getIpcLog() || [];
1233                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1234                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1235                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1236                    }})()",
1237                );
1238                self.eval_bridge(&code, None).await
1239            }
1240        }
1241    }
1242}
1243
1244impl VictauriMcpHandler {
1245    /// Create a new handler backed by the given state and webview bridge.
1246    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1247        Self {
1248            state,
1249            bridge,
1250            subscriptions: Arc::new(Mutex::new(HashSet::new())),
1251            bridge_checked: Arc::new(AtomicBool::new(false)),
1252        }
1253    }
1254
1255    fn track_tool_call(&self) {
1256        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
1257    }
1258
1259    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
1260        match self.eval_with_return(code, webview_label).await {
1261            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1262            Err(e) => tool_error(e),
1263        }
1264    }
1265
1266    async fn eval_with_return(
1267        &self,
1268        code: &str,
1269        webview_label: Option<&str>,
1270    ) -> Result<String, String> {
1271        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1272            .await
1273    }
1274
1275    async fn eval_with_return_timeout(
1276        &self,
1277        code: &str,
1278        webview_label: Option<&str>,
1279        timeout: std::time::Duration,
1280    ) -> Result<String, String> {
1281        self.track_tool_call();
1282        let id = uuid::Uuid::new_v4().to_string();
1283        let (tx, rx) = tokio::sync::oneshot::channel();
1284
1285        {
1286            let mut pending = self.state.pending_evals.lock().await;
1287            if pending.len() >= MAX_PENDING_EVALS {
1288                return Err(format!(
1289                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1290                ));
1291            }
1292            pending.insert(id.clone(), tx);
1293        }
1294
1295        // Auto-prepend `return` so bare expressions produce a value.
1296        // Only skip for code that starts with a statement keyword where
1297        // prepending `return` would be a syntax error.
1298        let code = code.trim();
1299        let needs_return = !code.starts_with("return ")
1300            && !code.starts_with("return;")
1301            && !code.starts_with('{')
1302            && !code.starts_with("if ")
1303            && !code.starts_with("if(")
1304            && !code.starts_with("for ")
1305            && !code.starts_with("for(")
1306            && !code.starts_with("while ")
1307            && !code.starts_with("while(")
1308            && !code.starts_with("switch ")
1309            && !code.starts_with("try ")
1310            && !code.starts_with("const ")
1311            && !code.starts_with("let ")
1312            && !code.starts_with("var ")
1313            && !code.starts_with("function ")
1314            && !code.starts_with("class ")
1315            && !code.starts_with("throw ");
1316        let code = if needs_return {
1317            format!("return {code}")
1318        } else {
1319            code.to_string()
1320        };
1321
1322        let inject = format!(
1323            r"
1324            (async () => {{
1325                try {{
1326                    const __result = await (async () => {{ {code} }})();
1327                    await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1328                        id: '{id}',
1329                        result: JSON.stringify(__result)
1330                    }});
1331                }} catch (e) {{
1332                    await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1333                        id: '{id}',
1334                        result: JSON.stringify({{ __error: e.message }})
1335                    }});
1336                }}
1337            }})();
1338            "
1339        );
1340
1341        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
1342            self.state.pending_evals.lock().await.remove(&id);
1343            return Err(format!("eval injection failed: {e}"));
1344        }
1345
1346        match tokio::time::timeout(timeout, rx).await {
1347            Ok(Ok(result)) => {
1348                self.check_bridge_version_once();
1349                Ok(result)
1350            }
1351            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
1352            Err(_) => {
1353                self.state.pending_evals.lock().await.remove(&id);
1354                Err(format!("eval timed out after {}s", timeout.as_secs()))
1355            }
1356        }
1357    }
1358
1359    fn check_bridge_version_once(&self) {
1360        if self.bridge_checked.swap(true, Ordering::Relaxed) {
1361            return;
1362        }
1363        let handler = self.clone();
1364        tokio::spawn(async move {
1365            match handler
1366                .eval_with_return_timeout(
1367                    "window.__VICTAURI__?.version",
1368                    None,
1369                    std::time::Duration::from_secs(5),
1370                )
1371                .await
1372            {
1373                Ok(v) => {
1374                    let v = v.trim_matches('"');
1375                    if v == BRIDGE_VERSION {
1376                        tracing::debug!("Bridge version verified: {v}");
1377                    } else {
1378                        tracing::warn!(
1379                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
1380                        );
1381                    }
1382                }
1383                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
1384            }
1385        });
1386    }
1387}
1388
1389const SERVER_INSTRUCTIONS: &str = "Victauri gives you X-ray vision and hands inside a running Tauri application. \
1390Use compound tools with an 'action' parameter to interact with the app: \
1391'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
1392'window' (get_state, list, manage, resize, move_to, set_title), \
1393'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
1394set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
1395get_events, events_between, get_replay, export, import), 'inspect' (get_styles, \
1396get_bounding_boxes, highlight, clear_highlights, audit_accessibility, get_performance), \
1397'css' (inject, remove), 'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
1398Standalone tools: eval_js, dom_snapshot, invoke_command, screenshot, verify_state, \
1399detect_ghost_commands, check_ipc_integrity, wait_for, assert_semantic, resolve_command, \
1400get_registry, get_memory_stats, get_plugin_info.";
1401
1402impl ServerHandler for VictauriMcpHandler {
1403    fn get_info(&self) -> ServerInfo {
1404        ServerInfo::new(
1405            ServerCapabilities::builder()
1406                .enable_tools()
1407                .enable_resources()
1408                .enable_resources_subscribe()
1409                .build(),
1410        )
1411        .with_instructions(SERVER_INSTRUCTIONS)
1412    }
1413
1414    async fn list_tools(
1415        &self,
1416        _request: Option<PaginatedRequestParams>,
1417        _context: RequestContext<RoleServer>,
1418    ) -> Result<ListToolsResult, ErrorData> {
1419        let all_tools = Self::tool_router().list_all();
1420        let filtered: Vec<Tool> = all_tools
1421            .into_iter()
1422            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1423            .collect();
1424        Ok(ListToolsResult {
1425            tools: filtered,
1426            ..Default::default()
1427        })
1428    }
1429
1430    async fn call_tool(
1431        &self,
1432        request: CallToolRequestParams,
1433        context: RequestContext<RoleServer>,
1434    ) -> Result<CallToolResult, ErrorData> {
1435        let tool_name: String = request.name.as_ref().to_owned();
1436        if !self.state.privacy.is_tool_enabled(&tool_name) {
1437            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
1438            return Ok(tool_disabled(&tool_name));
1439        }
1440        self.state
1441            .tool_invocations
1442            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1443        let start = std::time::Instant::now();
1444        tracing::debug!(tool = %tool_name, "tool invocation started");
1445        let ctx = ToolCallContext::new(self, request, context);
1446        let result = Self::tool_router().call(ctx).await;
1447        let elapsed = start.elapsed();
1448        tracing::debug!(
1449            tool = %tool_name,
1450            elapsed_ms = elapsed.as_millis() as u64,
1451            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
1452            "tool invocation completed"
1453        );
1454
1455        // Centralized output redaction: apply to all text content so no
1456        // individual tool can accidentally leak secrets.
1457        if self.state.privacy.redaction_enabled {
1458            result.map(|mut r| {
1459                for item in &mut r.content {
1460                    if let RawContent::Text(ref mut tc) = item.raw {
1461                        tc.text = self.state.privacy.redact_output(&tc.text);
1462                    }
1463                }
1464                r
1465            })
1466        } else {
1467            result
1468        }
1469    }
1470
1471    fn get_tool(&self, name: &str) -> Option<Tool> {
1472        if !self.state.privacy.is_tool_enabled(name) {
1473            return None;
1474        }
1475        Self::tool_router().get(name).cloned()
1476    }
1477
1478    async fn list_resources(
1479        &self,
1480        _request: Option<PaginatedRequestParams>,
1481        _context: RequestContext<RoleServer>,
1482    ) -> Result<ListResourcesResult, ErrorData> {
1483        Ok(ListResourcesResult {
1484            resources: vec![
1485                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
1486                    .with_description(
1487                        "Live IPC call log — all commands invoked between frontend and backend",
1488                    )
1489                    .with_mime_type("application/json")
1490                    .no_annotation(),
1491                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
1492                    .with_description(
1493                        "Current state of all Tauri windows — position, size, visibility, focus",
1494                    )
1495                    .with_mime_type("application/json")
1496                    .no_annotation(),
1497                RawResource::new(RESOURCE_URI_STATE, "state")
1498                    .with_description(
1499                        "Victauri plugin state — event count, registered commands, memory stats",
1500                    )
1501                    .with_mime_type("application/json")
1502                    .no_annotation(),
1503            ],
1504            ..Default::default()
1505        })
1506    }
1507
1508    async fn read_resource(
1509        &self,
1510        request: ReadResourceRequestParams,
1511        _context: RequestContext<RoleServer>,
1512    ) -> Result<ReadResourceResult, ErrorData> {
1513        let uri = &request.uri;
1514        let json = match uri.as_str() {
1515            RESOURCE_URI_IPC_LOG => {
1516                if let Ok(json) = self
1517                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
1518                    .await
1519                {
1520                    json
1521                } else {
1522                    let calls = self.state.event_log.ipc_calls();
1523                    serde_json::to_string_pretty(&calls)
1524                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1525                }
1526            }
1527            RESOURCE_URI_WINDOWS => {
1528                let states = self.bridge.get_window_states(None);
1529                serde_json::to_string_pretty(&states)
1530                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1531            }
1532            RESOURCE_URI_STATE => {
1533                let state_json = serde_json::json!({
1534                    "events_captured": self.state.event_log.len(),
1535                    "commands_registered": self.state.registry.count(),
1536                    "memory": crate::memory::current_stats(),
1537                    "port": self.state.port.load(Ordering::Relaxed),
1538                });
1539                serde_json::to_string_pretty(&state_json)
1540                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1541            }
1542            _ => {
1543                return Err(ErrorData::resource_not_found(
1544                    format!("unknown resource: {uri}"),
1545                    None,
1546                ));
1547            }
1548        };
1549
1550        Ok(ReadResourceResult::new(vec![ResourceContents::text(
1551            json, uri,
1552        )]))
1553    }
1554
1555    async fn subscribe(
1556        &self,
1557        request: SubscribeRequestParams,
1558        _context: RequestContext<RoleServer>,
1559    ) -> Result<(), ErrorData> {
1560        let uri = &request.uri;
1561        match uri.as_str() {
1562            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
1563                self.subscriptions.lock().await.insert(uri.clone());
1564                tracing::info!("Client subscribed to resource: {uri}");
1565                Ok(())
1566            }
1567            _ => Err(ErrorData::resource_not_found(
1568                format!("unknown resource: {uri}"),
1569                None,
1570            )),
1571        }
1572    }
1573
1574    async fn unsubscribe(
1575        &self,
1576        request: UnsubscribeRequestParams,
1577        _context: RequestContext<RoleServer>,
1578    ) -> Result<(), ErrorData> {
1579        self.subscriptions.lock().await.remove(&request.uri);
1580        tracing::info!("Client unsubscribed from resource: {}", request.uri);
1581        Ok(())
1582    }
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587    use super::*;
1588
1589    #[test]
1590    fn js_string_simple() {
1591        assert_eq!(js_string("hello"), "\"hello\"");
1592    }
1593
1594    #[test]
1595    fn js_string_single_quotes() {
1596        let result = js_string("it's a test");
1597        assert!(result.contains("it's a test"));
1598    }
1599
1600    #[test]
1601    fn js_string_double_quotes() {
1602        let result = js_string(r#"say "hello""#);
1603        assert!(result.contains(r#"\""#));
1604    }
1605
1606    #[test]
1607    fn js_string_backslashes() {
1608        let result = js_string(r"path\to\file");
1609        assert!(result.contains(r"\\"));
1610    }
1611
1612    #[test]
1613    fn js_string_newlines_and_tabs() {
1614        let result = js_string("line1\nline2\ttab");
1615        assert!(result.contains(r"\n"));
1616        assert!(result.contains(r"\t"));
1617        assert!(!result.contains('\n'));
1618    }
1619
1620    #[test]
1621    fn js_string_null_bytes() {
1622        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
1623        let result = js_string(&input);
1624        // serde_json escapes null bytes as
1625        assert!(result.contains("\\u0000"));
1626        assert!(!result.contains('\0'));
1627    }
1628
1629    #[test]
1630    fn js_string_template_literal_injection() {
1631        let result = js_string("`${alert(1)}`");
1632        // Should not contain unescaped backticks that could break template literals
1633        // serde_json wraps in double quotes, so backticks are safe
1634        assert!(result.starts_with('"'));
1635        assert!(result.ends_with('"'));
1636    }
1637
1638    #[test]
1639    fn js_string_unicode_separators() {
1640        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
1641        // JSON strings per RFC 8259, and serde_json passes them through literally.
1642        // Since js_string is used inside JS double-quoted strings (not template
1643        // literals), they are safe in modern JS engines (ES2019+).
1644        let result = js_string("a\u{2028}b\u{2029}c");
1645        // Verify the string is valid JSON that round-trips correctly
1646        let decoded: String = serde_json::from_str(&result).unwrap();
1647        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
1648    }
1649
1650    #[test]
1651    fn js_string_empty() {
1652        assert_eq!(js_string(""), "\"\"");
1653    }
1654
1655    #[test]
1656    fn js_string_html_script_close() {
1657        // </script> in a JS string inside HTML could break out of script tags
1658        let result = js_string("</script><img onerror=alert(1)>");
1659        assert!(result.starts_with('"'));
1660        // The string is JSON-encoded; verify it round-trips safely
1661        let decoded: String = serde_json::from_str(&result).unwrap();
1662        assert_eq!(decoded, "</script><img onerror=alert(1)>");
1663    }
1664
1665    #[test]
1666    fn js_string_very_long() {
1667        let long = "a".repeat(100_000);
1668        let result = js_string(&long);
1669        assert!(result.len() >= 100_002); // quotes + content
1670    }
1671
1672    // ── URL validation tests ────────────────────────────────────────────────
1673
1674    #[test]
1675    fn url_allows_http() {
1676        assert!(validate_url("http://example.com", false).is_ok());
1677    }
1678
1679    #[test]
1680    fn url_allows_https() {
1681        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
1682    }
1683
1684    #[test]
1685    fn url_allows_http_localhost() {
1686        assert!(validate_url("http://localhost:3000", false).is_ok());
1687    }
1688
1689    #[test]
1690    fn url_blocks_file_by_default() {
1691        let err = validate_url("file:///etc/passwd", false).unwrap_err();
1692        assert!(err.contains("file"), "error should mention the file scheme");
1693    }
1694
1695    #[test]
1696    fn url_allows_file_when_opted_in() {
1697        assert!(validate_url("file:///tmp/test.html", true).is_ok());
1698    }
1699
1700    #[test]
1701    fn url_blocks_javascript() {
1702        assert!(validate_url("javascript:alert(1)", false).is_err());
1703    }
1704
1705    #[test]
1706    fn url_blocks_javascript_case_insensitive() {
1707        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
1708    }
1709
1710    #[test]
1711    fn url_blocks_data_scheme() {
1712        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
1713    }
1714
1715    #[test]
1716    fn url_blocks_vbscript() {
1717        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
1718    }
1719
1720    #[test]
1721    fn url_rejects_invalid() {
1722        assert!(validate_url("not a url at all", false).is_err());
1723    }
1724
1725    #[test]
1726    fn url_strips_control_chars() {
1727        // Control characters should be stripped, leaving a valid URL
1728        let input = format!("http://example{}com", '\0');
1729        assert!(validate_url(&input, false).is_ok());
1730    }
1731
1732    // ── CSS color sanitization tests ───────────────────────────────────────
1733
1734    #[test]
1735    fn css_color_valid_hex() {
1736        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
1737        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
1738        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
1739    }
1740
1741    #[test]
1742    fn css_color_valid_rgb() {
1743        assert_eq!(
1744            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
1745            "rgb(255, 0, 0)"
1746        );
1747        assert_eq!(
1748            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
1749            "rgba(0, 0, 0, 0.5)"
1750        );
1751    }
1752
1753    #[test]
1754    fn css_color_valid_named() {
1755        assert_eq!(sanitize_css_color("red").unwrap(), "red");
1756        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
1757    }
1758
1759    #[test]
1760    fn css_color_valid_hsl() {
1761        assert_eq!(
1762            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
1763            "hsl(120, 50%, 50%)"
1764        );
1765    }
1766
1767    #[test]
1768    fn css_color_rejects_too_long() {
1769        let long = "a".repeat(101);
1770        assert!(sanitize_css_color(&long).is_err());
1771    }
1772
1773    #[test]
1774    fn css_color_rejects_backslash_escapes() {
1775        assert!(sanitize_css_color(r"red\00").is_err());
1776        assert!(sanitize_css_color(r"\72\65\64").is_err());
1777    }
1778
1779    #[test]
1780    fn css_color_rejects_url_injection() {
1781        assert!(sanitize_css_color("url(http://evil.com)").is_err());
1782        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
1783    }
1784
1785    #[test]
1786    fn css_color_rejects_expression_injection() {
1787        assert!(sanitize_css_color("expression(alert(1))").is_err());
1788        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
1789    }
1790
1791    #[test]
1792    fn css_color_rejects_import() {
1793        assert!(sanitize_css_color("@import url(evil.css)").is_err());
1794    }
1795
1796    #[test]
1797    fn css_color_rejects_semicolons_and_braces() {
1798        assert!(sanitize_css_color("red; background: url(evil)").is_err());
1799        assert!(sanitize_css_color("red} body { color: blue").is_err());
1800    }
1801
1802    #[test]
1803    fn css_color_rejects_special_chars() {
1804        assert!(sanitize_css_color("red<script>").is_err());
1805        assert!(sanitize_css_color("red\"onload=alert").is_err());
1806        assert!(sanitize_css_color("red'onclick=alert").is_err());
1807    }
1808
1809    #[test]
1810    fn css_color_trims_whitespace() {
1811        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
1812    }
1813
1814    #[test]
1815    fn css_color_empty_string() {
1816        assert_eq!(sanitize_css_color("").unwrap(), "");
1817    }
1818}