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