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