Skip to main content

victauri_plugin/
mcp.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use rmcp::handler::server::tool::ToolCallContext;
6use rmcp::handler::server::wrapper::Parameters;
7use rmcp::model::{
8    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
9    ListToolsResult, PaginatedRequestParams, RawResource, ReadResourceRequestParams,
10    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
11    Tool, UnsubscribeRequestParams,
12};
13use rmcp::service::RequestContext;
14use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
15use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
16use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
17use schemars::JsonSchema;
18use serde::Deserialize;
19use tauri::Runtime;
20use tokio::sync::Mutex;
21
22use crate::VictauriState;
23use crate::bridge::WebviewBridge;
24
25/// Produce a properly escaped JavaScript string literal (with double quotes).
26/// Uses serde_json which handles all special characters: \n, \r, \0, \t,
27/// unicode escapes, quotes, backslashes, etc.
28fn js_string(s: &str) -> String {
29    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
30}
31
32// ── Parameter structs ────────────────────────────────────────────────────────
33
34#[derive(Debug, Deserialize, JsonSchema)]
35pub struct EvalJsParams {
36    /// JavaScript code to evaluate in the webview. Async expressions supported.
37    pub code: String,
38    /// Target webview label. If omitted, targets the first available webview.
39    pub webview_label: Option<String>,
40}
41
42#[derive(Debug, Deserialize, JsonSchema)]
43pub struct ClickParams {
44    /// Ref handle ID from a DOM snapshot (e.g. "e5").
45    pub ref_id: String,
46    /// Target webview label.
47    pub webview_label: Option<String>,
48}
49
50#[derive(Debug, Deserialize, JsonSchema)]
51pub struct FillParams {
52    /// Ref handle ID of the input element.
53    pub ref_id: String,
54    /// Value to set on the input.
55    pub value: String,
56    /// Target webview label.
57    pub webview_label: Option<String>,
58}
59
60#[derive(Debug, Deserialize, JsonSchema)]
61pub struct TypeTextParams {
62    /// Ref handle ID of the element to type into.
63    pub ref_id: String,
64    /// Text to type character by character.
65    pub text: String,
66    /// Target webview label.
67    pub webview_label: Option<String>,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct SnapshotParams {
72    /// Target webview label. If omitted, targets the first available webview.
73    pub webview_label: Option<String>,
74}
75
76#[derive(Debug, Deserialize, JsonSchema)]
77pub struct WindowStateParams {
78    /// Filter to a specific window label. Returns all windows if omitted.
79    pub label: Option<String>,
80}
81
82#[derive(Debug, Deserialize, JsonSchema)]
83pub struct IpcLogParams {
84    /// Maximum number of most recent entries to return.
85    pub limit: Option<usize>,
86    /// Target webview label.
87    pub webview_label: Option<String>,
88}
89
90#[derive(Debug, Deserialize, JsonSchema)]
91pub struct RegistryParams {
92    /// Search query to filter commands by name or description.
93    pub query: Option<String>,
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct VerifyStateParams {
98    /// JavaScript expression that returns the frontend state object to compare.
99    pub frontend_expr: String,
100    /// Backend state as a JSON object to compare against.
101    pub backend_state: serde_json::Value,
102    /// Target webview label.
103    pub webview_label: Option<String>,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct GhostCommandParams {
108    /// Optional filter: only consider IPC calls from this webview label.
109    pub webview_label: Option<String>,
110}
111
112#[derive(Debug, Deserialize, JsonSchema)]
113pub struct IpcIntegrityParams {
114    /// Age in milliseconds after which a pending IPC call is considered stale. Default: 5000.
115    pub stale_threshold_ms: Option<i64>,
116    /// Target webview label.
117    pub webview_label: Option<String>,
118}
119
120#[derive(Debug, Deserialize, JsonSchema)]
121pub struct EventStreamParams {
122    /// Only return events after this Unix timestamp (milliseconds). If omitted, returns all events.
123    pub since: Option<f64>,
124    /// Target webview label.
125    pub webview_label: Option<String>,
126}
127
128#[derive(Debug, Deserialize, JsonSchema)]
129pub struct StartRecordingParams {
130    /// Optional session ID. If omitted, a UUID is generated.
131    pub session_id: Option<String>,
132}
133
134#[derive(Debug, Deserialize, JsonSchema)]
135pub struct CheckpointParams {
136    /// Unique ID for this checkpoint.
137    pub id: String,
138    /// Optional human-readable label for the checkpoint.
139    pub label: Option<String>,
140    /// State snapshot as JSON to associate with this checkpoint.
141    pub state: serde_json::Value,
142}
143
144#[derive(Debug, Deserialize, JsonSchema)]
145pub struct ReplayParams {
146    /// Only return events after this index.
147    pub since_index: Option<usize>,
148}
149
150#[derive(Debug, Deserialize, JsonSchema)]
151pub struct EventsBetweenCheckpointsParams {
152    /// Checkpoint ID to start from.
153    pub from_checkpoint: String,
154    /// Checkpoint ID to end at.
155    pub to_checkpoint: String,
156}
157
158#[derive(Debug, Deserialize, JsonSchema)]
159pub struct ResolveCommandParams {
160    /// Natural language query describing what you want to do (e.g. "save the user's settings").
161    pub query: String,
162    /// Maximum number of results to return. Default: 5.
163    pub limit: Option<usize>,
164}
165
166#[derive(Debug, Deserialize, JsonSchema)]
167pub struct SemanticAssertParams {
168    /// JavaScript expression to evaluate in the webview. The result is checked against the assertion.
169    pub expression: String,
170    /// Human-readable label for this assertion (e.g. "user is logged in").
171    pub label: String,
172    /// Condition: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is.
173    pub condition: String,
174    /// Expected value for the assertion.
175    pub expected: serde_json::Value,
176    /// Target webview label.
177    pub webview_label: Option<String>,
178}
179
180#[derive(Debug, Deserialize, JsonSchema)]
181pub struct InvokeCommandParams {
182    /// The Tauri command name to invoke (e.g. "greet", "save_settings").
183    pub command: String,
184    /// Arguments as a JSON object. Keys are parameter names. Omit for commands with no arguments.
185    pub args: Option<serde_json::Value>,
186    /// Target webview label.
187    pub webview_label: Option<String>,
188}
189
190#[derive(Debug, Deserialize, JsonSchema)]
191pub struct ScreenshotParams {
192    /// Target window label. If omitted, captures the main/first visible window.
193    pub window_label: Option<String>,
194}
195
196#[derive(Debug, Deserialize, JsonSchema)]
197pub struct PressKeyParams {
198    /// Key to press (e.g. "Enter", "Escape", "Tab", "ArrowDown").
199    pub key: String,
200    /// Target webview label.
201    pub webview_label: Option<String>,
202}
203
204#[derive(Debug, Deserialize, JsonSchema)]
205pub struct GetConsoleLogsParams {
206    /// Only return logs after this Unix timestamp (milliseconds). If omitted, returns all captured logs.
207    pub since: Option<f64>,
208    /// Target webview label.
209    pub webview_label: Option<String>,
210}
211
212#[derive(Debug, Deserialize, JsonSchema)]
213pub struct DoubleClickParams {
214    /// Ref handle ID from a DOM snapshot.
215    pub ref_id: String,
216    /// Target webview label.
217    pub webview_label: Option<String>,
218}
219
220#[derive(Debug, Deserialize, JsonSchema)]
221pub struct HoverParams {
222    /// Ref handle ID from a DOM snapshot.
223    pub ref_id: String,
224    /// Target webview label.
225    pub webview_label: Option<String>,
226}
227
228#[derive(Debug, Deserialize, JsonSchema)]
229pub struct SelectOptionParams {
230    /// Ref handle ID of the `<select>` element.
231    pub ref_id: String,
232    /// Values to select.
233    pub values: Vec<String>,
234    /// Target webview label.
235    pub webview_label: Option<String>,
236}
237
238#[derive(Debug, Deserialize, JsonSchema)]
239pub struct ScrollToParams {
240    /// Ref handle ID to scroll into view. If null, scrolls to absolute coordinates.
241    pub ref_id: Option<String>,
242    /// Horizontal scroll position (pixels). Used when ref_id is null.
243    pub x: Option<f64>,
244    /// Vertical scroll position (pixels). Used when ref_id is null.
245    pub y: Option<f64>,
246    /// Target webview label.
247    pub webview_label: Option<String>,
248}
249
250#[derive(Debug, Deserialize, JsonSchema)]
251pub struct FocusElementParams {
252    /// Ref handle ID of the element to focus.
253    pub ref_id: String,
254    /// Target webview label.
255    pub webview_label: Option<String>,
256}
257
258#[derive(Debug, Deserialize, JsonSchema)]
259pub struct NetworkLogParams {
260    /// Filter by URL substring.
261    pub filter: Option<String>,
262    /// Maximum number of entries to return.
263    pub limit: Option<usize>,
264    /// Target webview label.
265    pub webview_label: Option<String>,
266}
267
268#[derive(Debug, Deserialize, JsonSchema)]
269pub struct GetStorageParams {
270    /// Storage type: "local" or "session".
271    pub storage_type: String,
272    /// Specific key to read. If omitted, returns all entries.
273    pub key: Option<String>,
274    /// Target webview label.
275    pub webview_label: Option<String>,
276}
277
278#[derive(Debug, Deserialize, JsonSchema)]
279pub struct SetStorageParams {
280    /// Storage type: "local" or "session".
281    pub storage_type: String,
282    /// Key to set.
283    pub key: String,
284    /// Value to store (will be JSON-serialized if not a string).
285    pub value: serde_json::Value,
286    /// Target webview label.
287    pub webview_label: Option<String>,
288}
289
290#[derive(Debug, Deserialize, JsonSchema)]
291pub struct DeleteStorageParams {
292    /// Storage type: "local" or "session".
293    pub storage_type: String,
294    /// Key to delete.
295    pub key: String,
296    /// Target webview label.
297    pub webview_label: Option<String>,
298}
299
300#[derive(Debug, Deserialize, JsonSchema)]
301pub struct GetCookiesParams {
302    /// Target webview label.
303    pub webview_label: Option<String>,
304}
305
306#[derive(Debug, Deserialize, JsonSchema)]
307pub struct NavigationLogParams {
308    /// Target webview label.
309    pub webview_label: Option<String>,
310}
311
312#[derive(Debug, Deserialize, JsonSchema)]
313pub struct NavigateParams {
314    /// URL to navigate to.
315    pub url: String,
316    /// Target webview label.
317    pub webview_label: Option<String>,
318}
319
320#[derive(Debug, Deserialize, JsonSchema)]
321pub struct DialogLogParams {
322    /// Target webview label.
323    pub webview_label: Option<String>,
324}
325
326#[derive(Debug, Deserialize, JsonSchema)]
327pub struct SetDialogResponseParams {
328    /// Dialog type: "alert", "confirm", or "prompt".
329    pub dialog_type: String,
330    /// Action: "accept" or "dismiss".
331    pub action: String,
332    /// Response text for prompt dialogs.
333    pub text: Option<String>,
334    /// Target webview label.
335    pub webview_label: Option<String>,
336}
337
338#[derive(Debug, Deserialize, JsonSchema)]
339pub struct WaitForParams {
340    /// Condition to wait for: text, text_gone, selector, selector_gone, url, ipc_idle, network_idle.
341    pub condition: String,
342    /// Value for the condition (text to find, CSS selector, URL substring).
343    pub value: Option<String>,
344    /// Maximum time to wait in milliseconds. Default: 10000.
345    pub timeout_ms: Option<u64>,
346    /// Polling interval in milliseconds. Default: 200.
347    pub poll_ms: Option<u64>,
348    /// Target webview label.
349    pub webview_label: Option<String>,
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
353pub struct ManageWindowParams {
354    /// Action: minimize, unminimize, maximize, unmaximize, close, focus, show, hide, fullscreen, unfullscreen, always_on_top, not_always_on_top.
355    pub action: String,
356    /// Target window label.
357    pub label: Option<String>,
358}
359
360#[derive(Debug, Deserialize, JsonSchema)]
361pub struct ResizeWindowParams {
362    /// Width in logical pixels.
363    pub width: u32,
364    /// Height in logical pixels.
365    pub height: u32,
366    /// Target window label.
367    pub label: Option<String>,
368}
369
370#[derive(Debug, Deserialize, JsonSchema)]
371pub struct MoveWindowParams {
372    /// X position in logical pixels.
373    pub x: i32,
374    /// Y position in logical pixels.
375    pub y: i32,
376    /// Target window label.
377    pub label: Option<String>,
378}
379
380#[derive(Debug, Deserialize, JsonSchema)]
381pub struct SetWindowTitleParams {
382    /// New window title.
383    pub title: String,
384    /// Target window label.
385    pub label: Option<String>,
386}
387
388#[derive(Debug, Deserialize, JsonSchema)]
389pub struct GetStylesParams {
390    /// Ref handle ID of the element to inspect.
391    pub ref_id: String,
392    /// Optional list of CSS property names to return. If omitted, returns key properties.
393    pub properties: Option<Vec<String>>,
394    /// Target webview label.
395    pub webview_label: Option<String>,
396}
397
398#[derive(Debug, Deserialize, JsonSchema)]
399pub struct GetBoundingBoxesParams {
400    /// List of ref handle IDs to measure.
401    pub ref_ids: Vec<String>,
402    /// Target webview label.
403    pub webview_label: Option<String>,
404}
405
406#[derive(Debug, Deserialize, JsonSchema)]
407pub struct HighlightElementParams {
408    /// Ref handle ID of the element to highlight.
409    pub ref_id: String,
410    /// CSS color for the overlay (default: "rgba(255, 0, 0, 0.3)").
411    pub color: Option<String>,
412    /// Optional text label to display above the highlight.
413    pub label: Option<String>,
414    /// Target webview label.
415    pub webview_label: Option<String>,
416}
417
418#[derive(Debug, Deserialize, JsonSchema)]
419pub struct ClearHighlightsParams {
420    /// Target webview label.
421    pub webview_label: Option<String>,
422}
423
424#[derive(Debug, Deserialize, JsonSchema)]
425pub struct InjectCssParams {
426    /// CSS text to inject into the page.
427    pub css: String,
428    /// Target webview label.
429    pub webview_label: Option<String>,
430}
431
432#[derive(Debug, Deserialize, JsonSchema)]
433pub struct RemoveInjectedCssParams {
434    /// Target webview label.
435    pub webview_label: Option<String>,
436}
437
438#[derive(Debug, Deserialize, JsonSchema)]
439pub struct AuditAccessibilityParams {
440    /// Target webview label.
441    pub webview_label: Option<String>,
442}
443
444#[derive(Debug, Deserialize, JsonSchema)]
445pub struct GetPerformanceMetricsParams {
446    /// Target webview label.
447    pub webview_label: Option<String>,
448}
449
450#[derive(Debug, Deserialize, JsonSchema)]
451pub struct ImportSessionParams {
452    /// JSON string of a previously exported RecordedSession.
453    pub session_json: String,
454}
455
456#[derive(Debug, Deserialize, JsonSchema)]
457pub struct SlowIpcParams {
458    /// Threshold in milliseconds. Returns IPC calls slower than this value.
459    pub threshold_ms: u64,
460    /// Maximum number of results. Default: 20.
461    pub limit: Option<usize>,
462}
463
464// ── MCP Handler ──────────────────────────────────────────────────────────────
465
466/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
467/// growth of the `pending_evals` map if callbacks are never resolved.
468const MAX_PENDING_EVALS: usize = 100;
469
470const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
471const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
472const RESOURCE_URI_STATE: &str = "victauri://state";
473
474const BRIDGE_VERSION: &str = "0.2.0";
475
476#[derive(Clone)]
477pub struct VictauriMcpHandler {
478    state: Arc<VictauriState>,
479    bridge: Arc<dyn WebviewBridge>,
480    subscriptions: Arc<Mutex<HashSet<String>>>,
481    bridge_checked: Arc<AtomicBool>,
482}
483
484#[tool_router]
485impl VictauriMcpHandler {
486    #[tool(
487        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically."
488    )]
489    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
490        if !self.state.privacy.is_tool_enabled("eval_js") {
491            return tool_disabled("eval_js");
492        }
493        match self
494            .eval_with_return(&params.code, params.webview_label.as_deref())
495            .await
496        {
497            Ok(result) => self.redact_result(result),
498            Err(e) => tool_error(e),
499        }
500    }
501
502    #[tool(
503        description = "Get the current DOM snapshot from the webview as a JSON accessibility tree with ref handles for interaction."
504    )]
505    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
506        let code = "return window.__VICTAURI__?.snapshot()";
507        match self
508            .eval_with_return(code, params.webview_label.as_deref())
509            .await
510        {
511            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
512            Err(e) => tool_error(e),
513        }
514    }
515
516    #[tool(description = "Click an element by its ref handle ID from a DOM snapshot.")]
517    async fn click(&self, Parameters(params): Parameters<ClickParams>) -> CallToolResult {
518        let code = format!(
519            "return window.__VICTAURI__?.click({})",
520            js_string(&params.ref_id)
521        );
522        match self
523            .eval_with_return(&code, params.webview_label.as_deref())
524            .await
525        {
526            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
527            Err(e) => tool_error(e),
528        }
529    }
530
531    #[tool(
532        description = "Set the value of an input element by ref handle ID. Dispatches input and change events."
533    )]
534    async fn fill(&self, Parameters(params): Parameters<FillParams>) -> CallToolResult {
535        if !self.state.privacy.is_tool_enabled("fill") {
536            return tool_disabled("fill");
537        }
538        let code = format!(
539            "return window.__VICTAURI__?.fill({}, {})",
540            js_string(&params.ref_id),
541            js_string(&params.value)
542        );
543        match self
544            .eval_with_return(&code, params.webview_label.as_deref())
545            .await
546        {
547            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
548            Err(e) => tool_error(e),
549        }
550    }
551
552    #[tool(
553        description = "Type text character-by-character into an element, simulating real keyboard events."
554    )]
555    async fn type_text(&self, Parameters(params): Parameters<TypeTextParams>) -> CallToolResult {
556        if !self.state.privacy.is_tool_enabled("type_text") {
557            return tool_disabled("type_text");
558        }
559        let code = format!(
560            "return window.__VICTAURI__?.type({}, {})",
561            js_string(&params.ref_id),
562            js_string(&params.text)
563        );
564        match self
565            .eval_with_return(&code, params.webview_label.as_deref())
566            .await
567        {
568            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
569            Err(e) => tool_error(e),
570        }
571    }
572
573    #[tool(
574        description = "Get state of all Tauri windows: position, size, visibility, focus, and URL."
575    )]
576    async fn get_window_state(
577        &self,
578        Parameters(params): Parameters<WindowStateParams>,
579    ) -> CallToolResult {
580        let states = self.bridge.get_window_states(params.label.as_deref());
581        match serde_json::to_string_pretty(&states) {
582            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
583            Err(e) => tool_error(e.to_string()),
584        }
585    }
586
587    #[tool(description = "List all Tauri window labels.")]
588    async fn list_windows(&self) -> CallToolResult {
589        let labels = self.bridge.list_window_labels();
590        match serde_json::to_string_pretty(&labels) {
591            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
592            Err(e) => tool_error(e.to_string()),
593        }
594    }
595
596    #[tool(
597        description = "Get recent IPC calls intercepted by the JS bridge. Returns command names, arguments, results, durations, and status (ok/error/pending)."
598    )]
599    async fn get_ipc_log(&self, Parameters(params): Parameters<IpcLogParams>) -> CallToolResult {
600        let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
601        let code = if limit_arg.is_empty() {
602            "return window.__VICTAURI__?.getIpcLog()".to_string()
603        } else {
604            format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
605        };
606        match self
607            .eval_with_return(&code, params.webview_label.as_deref())
608            .await
609        {
610            Ok(result) => self.redact_result(result),
611            Err(e) => tool_error(e),
612        }
613    }
614
615    #[tool(
616        description = "List or search all registered Tauri commands with their argument schemas."
617    )]
618    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
619        let commands = match params.query {
620            Some(q) => self.state.registry.search(&q),
621            None => self.state.registry.list(),
622        };
623        match serde_json::to_string_pretty(&commands) {
624            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
625            Err(e) => tool_error(e.to_string()),
626        }
627    }
628
629    #[tool(
630        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."
631    )]
632    async fn get_memory_stats(&self) -> CallToolResult {
633        let stats = crate::memory::current_stats();
634        match serde_json::to_string_pretty(&stats) {
635            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
636            Err(e) => tool_error(e.to_string()),
637        }
638    }
639
640    #[tool(
641        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches."
642    )]
643    async fn verify_state(
644        &self,
645        Parameters(params): Parameters<VerifyStateParams>,
646    ) -> CallToolResult {
647        let code = format!("return ({})", params.frontend_expr);
648        let frontend_json = match self
649            .eval_with_return(&code, params.webview_label.as_deref())
650            .await
651        {
652            Ok(result) => result,
653            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
654        };
655
656        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
657            Ok(v) => v,
658            Err(e) => {
659                return tool_error(format!(
660                    "frontend expression did not return valid JSON: {e}"
661                ));
662            }
663        };
664
665        let result = victauri_core::verify_state(frontend_state, params.backend_state);
666        match serde_json::to_string_pretty(&result) {
667            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
668            Err(e) => tool_error(e.to_string()),
669        }
670    }
671
672    #[tool(
673        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."
674    )]
675    async fn detect_ghost_commands(
676        &self,
677        Parameters(params): Parameters<GhostCommandParams>,
678    ) -> CallToolResult {
679        let code = "return window.__VICTAURI__?.getIpcLog()";
680        let ipc_json = match self
681            .eval_with_return(code, params.webview_label.as_deref())
682            .await
683        {
684            Ok(r) => r,
685            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
686        };
687
688        let ipc_calls: Vec<serde_json::Value> = serde_json::from_str(&ipc_json).unwrap_or_default();
689        let frontend_commands: Vec<String> = ipc_calls
690            .iter()
691            .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
692            .collect::<std::collections::HashSet<_>>()
693            .into_iter()
694            .collect();
695
696        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
697        match serde_json::to_string_pretty(&report) {
698            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
699            Err(e) => tool_error(e.to_string()),
700        }
701    }
702
703    #[tool(
704        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls."
705    )]
706    async fn check_ipc_integrity(
707        &self,
708        Parameters(params): Parameters<IpcIntegrityParams>,
709    ) -> CallToolResult {
710        let threshold = params.stale_threshold_ms.unwrap_or(5000);
711        let code = format!(
712            r#"return (function() {{
713                var log = window.__VICTAURI__?.getIpcLog() || [];
714                var now = Date.now();
715                var threshold = {threshold};
716                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
717                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
718                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
719                return {{
720                    healthy: stale.length === 0 && errored.length === 0,
721                    total_calls: log.length,
722                    pending_count: pending.length,
723                    stale_count: stale.length,
724                    error_count: errored.length,
725                    stale_calls: stale.slice(0, 20),
726                    errored_calls: errored.slice(0, 20)
727                }};
728            }})()"#
729        );
730        match self
731            .eval_with_return(&code, params.webview_label.as_deref())
732            .await
733        {
734            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
735            Err(e) => tool_error(e),
736        }
737    }
738
739    #[tool(
740        description = "Get a combined event stream from the webview: console logs, DOM mutations, sorted by timestamp. Use the 'since' parameter to poll only new events."
741    )]
742    async fn get_event_stream(
743        &self,
744        Parameters(params): Parameters<EventStreamParams>,
745    ) -> CallToolResult {
746        let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
747        let code = if since_arg.is_empty() {
748            "return window.__VICTAURI__?.getEventStream()".to_string()
749        } else {
750            format!("return window.__VICTAURI__?.getEventStream({since_arg})")
751        };
752        match self
753            .eval_with_return(&code, params.webview_label.as_deref())
754            .await
755        {
756            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
757            Err(e) => tool_error(e),
758        }
759    }
760
761    #[tool(
762        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples."
763    )]
764    async fn resolve_command(
765        &self,
766        Parameters(params): Parameters<ResolveCommandParams>,
767    ) -> CallToolResult {
768        let limit = params.limit.unwrap_or(5);
769        let mut results = self.state.registry.resolve(&params.query);
770        results.truncate(limit);
771        match serde_json::to_string_pretty(&results) {
772            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
773            Err(e) => tool_error(e.to_string()),
774        }
775    }
776
777    #[tool(
778        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."
779    )]
780    async fn assert_semantic(
781        &self,
782        Parameters(params): Parameters<SemanticAssertParams>,
783    ) -> CallToolResult {
784        let code = format!("return ({})", params.expression);
785        let actual_json = match self
786            .eval_with_return(&code, params.webview_label.as_deref())
787            .await
788        {
789            Ok(result) => result,
790            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
791        };
792
793        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
794            Ok(v) => v,
795            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
796        };
797
798        let assertion = victauri_core::SemanticAssertion {
799            label: params.label,
800            condition: params.condition,
801            expected: params.expected,
802        };
803
804        let result = victauri_core::evaluate_assertion(actual, &assertion);
805        match serde_json::to_string_pretty(&result) {
806            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
807            Err(e) => tool_error(e.to_string()),
808        }
809    }
810
811    #[tool(
812        description = "Start recording IPC events and state changes. Returns false if a recording is already active."
813    )]
814    async fn start_recording(
815        &self,
816        Parameters(params): Parameters<StartRecordingParams>,
817    ) -> CallToolResult {
818        let session_id = params
819            .session_id
820            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
821        let started = self.state.recorder.start(session_id.clone());
822        let result = serde_json::json!({
823            "started": started,
824            "session_id": session_id,
825        });
826        CallToolResult::success(vec![Content::text(result.to_string())])
827    }
828
829    #[tool(
830        description = "Stop the current recording and return the full recorded session with all events and checkpoints."
831    )]
832    async fn stop_recording(&self) -> CallToolResult {
833        match self.state.recorder.stop() {
834            Some(session) => match serde_json::to_string_pretty(&session) {
835                Ok(json) => CallToolResult::success(vec![Content::text(json)]),
836                Err(e) => tool_error(e.to_string()),
837            },
838            None => tool_error("no recording is active"),
839        }
840    }
841
842    #[tool(
843        description = "Create a state checkpoint during recording. Associates the current event index with a state snapshot for later comparison."
844    )]
845    async fn checkpoint(&self, Parameters(params): Parameters<CheckpointParams>) -> CallToolResult {
846        let created = self
847            .state
848            .recorder
849            .checkpoint(params.id.clone(), params.label, params.state);
850        if created {
851            let result = serde_json::json!({
852                "created": true,
853                "checkpoint_id": params.id,
854                "event_index": self.state.recorder.event_count(),
855            });
856            CallToolResult::success(vec![Content::text(result.to_string())])
857        } else {
858            tool_error("no recording is active — start one first")
859        }
860    }
861
862    #[tool(description = "Get all checkpoints from the current recording session.")]
863    async fn list_checkpoints(&self) -> CallToolResult {
864        let checkpoints = self.state.recorder.get_checkpoints();
865        match serde_json::to_string_pretty(&checkpoints) {
866            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
867            Err(e) => tool_error(e.to_string()),
868        }
869    }
870
871    #[tool(
872        description = "Get the IPC replay sequence: all IPC calls recorded in order, suitable for replaying the session."
873    )]
874    async fn get_replay_sequence(&self) -> CallToolResult {
875        let calls = self.state.recorder.ipc_replay_sequence();
876        match serde_json::to_string_pretty(&calls) {
877            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
878            Err(e) => tool_error(e.to_string()),
879        }
880    }
881
882    #[tool(
883        description = "Get recorded events since a specific event index. Useful for incremental replay."
884    )]
885    async fn get_recorded_events(
886        &self,
887        Parameters(params): Parameters<ReplayParams>,
888    ) -> CallToolResult {
889        let events = self
890            .state
891            .recorder
892            .events_since(params.since_index.unwrap_or(0));
893        match serde_json::to_string_pretty(&events) {
894            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
895            Err(e) => tool_error(e.to_string()),
896        }
897    }
898
899    #[tool(description = "Get all events that occurred between two checkpoints.")]
900    async fn events_between_checkpoints(
901        &self,
902        Parameters(params): Parameters<EventsBetweenCheckpointsParams>,
903    ) -> CallToolResult {
904        match self
905            .state
906            .recorder
907            .events_between_checkpoints(&params.from_checkpoint, &params.to_checkpoint)
908        {
909            Some(events) => match serde_json::to_string_pretty(&events) {
910                Ok(json) => CallToolResult::success(vec![Content::text(json)]),
911                Err(e) => tool_error(e.to_string()),
912            },
913            None => tool_error("one or both checkpoint IDs not found"),
914        }
915    }
916
917    #[tool(
918        description = "Export the current recording session as a JSON string. The session can be saved externally and later imported with import_session. Does NOT stop the recording."
919    )]
920    async fn export_session(&self) -> CallToolResult {
921        let rec = self.state.recorder.clone();
922        if !rec.is_recording() {
923            return tool_error("no recording is active — start one first");
924        }
925        let session = rec.stop();
926        match session {
927            Some(s) => {
928                let json = serde_json::to_string_pretty(&s)
929                    .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
930                CallToolResult::success(vec![Content::text(json)])
931            }
932            None => tool_error("failed to export session"),
933        }
934    }
935
936    #[tool(
937        description = "Import a previously exported recording session from JSON. Useful for replaying sessions across restarts. The imported session can be queried with replay and checkpoint tools."
938    )]
939    async fn import_session(
940        &self,
941        Parameters(params): Parameters<ImportSessionParams>,
942    ) -> CallToolResult {
943        let session: victauri_core::RecordedSession =
944            match serde_json::from_str(&params.session_json) {
945                Ok(s) => s,
946                Err(e) => return tool_error(format!("invalid session JSON: {e}")),
947            };
948
949        let result = serde_json::json!({
950            "imported": true,
951            "session_id": session.id,
952            "event_count": session.events.len(),
953            "checkpoint_count": session.checkpoints.len(),
954            "started_at": session.started_at.to_rfc3339(),
955        });
956        CallToolResult::success(vec![Content::text(result.to_string())])
957    }
958
959    #[tool(
960        description = "Find slow IPC calls that exceed a time threshold. Returns calls sorted by duration (slowest first). Useful for identifying performance bottlenecks."
961    )]
962    async fn slow_ipc_calls(
963        &self,
964        Parameters(params): Parameters<SlowIpcParams>,
965    ) -> CallToolResult {
966        let limit = params.limit.unwrap_or(20);
967        let mut calls: Vec<_> = self
968            .state
969            .event_log
970            .ipc_calls()
971            .into_iter()
972            .filter(|c| c.duration_ms.unwrap_or(0) > params.threshold_ms)
973            .collect();
974        calls.sort_by_key(|c| std::cmp::Reverse(c.duration_ms));
975        calls.truncate(limit);
976
977        let result = serde_json::json!({
978            "threshold_ms": params.threshold_ms,
979            "count": calls.len(),
980            "calls": calls,
981        });
982        match serde_json::to_string_pretty(&result) {
983            Ok(json) => self.redact_result(json),
984            Err(e) => tool_error(e.to_string()),
985        }
986    }
987
988    #[tool(
989        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."
990    )]
991    async fn invoke_command(
992        &self,
993        Parameters(params): Parameters<InvokeCommandParams>,
994    ) -> CallToolResult {
995        if !self.state.privacy.is_command_allowed(&params.command) {
996            return tool_error(format!(
997                "command '{}' is blocked by privacy configuration",
998                params.command
999            ));
1000        }
1001        let args_json = params.args.unwrap_or(serde_json::json!({}));
1002        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1003        let code = format!(
1004            "return window.__TAURI__.core.invoke({}, {args_str})",
1005            js_string(&params.command)
1006        );
1007        match self
1008            .eval_with_return(&code, params.webview_label.as_deref())
1009            .await
1010        {
1011            Ok(result) => self.redact_result(result),
1012            Err(e) => tool_error(format!("invoke_command failed: {e}")),
1013        }
1014    }
1015
1016    #[tool(
1017        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Currently supported on Windows; other platforms return an error."
1018    )]
1019    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
1020        if !self.state.privacy.is_tool_enabled("screenshot") {
1021            return tool_disabled("screenshot");
1022        }
1023        match self
1024            .bridge
1025            .get_native_handle(params.window_label.as_deref())
1026        {
1027            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
1028                Ok(png_bytes) => {
1029                    use base64::Engine;
1030                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
1031                    CallToolResult::success(vec![Content::image(b64, "image/png")])
1032                }
1033                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
1034            },
1035            Err(e) => tool_error(format!("cannot get window handle: {e}")),
1036        }
1037    }
1038
1039    #[tool(
1040        description = "Press a keyboard key on the currently focused element. Useful for triggering keyboard shortcuts, submitting forms (Enter), closing dialogs (Escape), or navigating (Tab, ArrowDown)."
1041    )]
1042    async fn press_key(&self, Parameters(params): Parameters<PressKeyParams>) -> CallToolResult {
1043        let code = format!(
1044            "return window.__VICTAURI__?.pressKey({})",
1045            js_string(&params.key)
1046        );
1047        match self
1048            .eval_with_return(&code, params.webview_label.as_deref())
1049            .await
1050        {
1051            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1052            Err(e) => tool_error(e),
1053        }
1054    }
1055
1056    #[tool(
1057        description = "Get captured console logs (log, warn, error, info) from the webview. Useful for debugging and monitoring application behavior."
1058    )]
1059    async fn get_console_logs(
1060        &self,
1061        Parameters(params): Parameters<GetConsoleLogsParams>,
1062    ) -> CallToolResult {
1063        let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1064        let code = if since_arg.is_empty() {
1065            "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1066        } else {
1067            format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1068        };
1069        match self
1070            .eval_with_return(&code, params.webview_label.as_deref())
1071            .await
1072        {
1073            Ok(result) => self.redact_result(result),
1074            Err(e) => tool_error(e),
1075        }
1076    }
1077
1078    // ── Extended Interactions ────────────────────────────────────────────────
1079
1080    #[tool(description = "Double-click an element by its ref handle ID from a DOM snapshot.")]
1081    async fn double_click(
1082        &self,
1083        Parameters(params): Parameters<DoubleClickParams>,
1084    ) -> CallToolResult {
1085        let code = format!(
1086            "return window.__VICTAURI__?.doubleClick({})",
1087            js_string(&params.ref_id)
1088        );
1089        match self
1090            .eval_with_return(&code, params.webview_label.as_deref())
1091            .await
1092        {
1093            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1094            Err(e) => tool_error(e),
1095        }
1096    }
1097
1098    #[tool(
1099        description = "Hover over an element by its ref handle ID. Dispatches mouseenter and mouseover events."
1100    )]
1101    async fn hover(&self, Parameters(params): Parameters<HoverParams>) -> CallToolResult {
1102        let code = format!(
1103            "return window.__VICTAURI__?.hover({})",
1104            js_string(&params.ref_id)
1105        );
1106        match self
1107            .eval_with_return(&code, params.webview_label.as_deref())
1108            .await
1109        {
1110            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1111            Err(e) => tool_error(e),
1112        }
1113    }
1114
1115    #[tool(
1116        description = "Select one or more options in a <select> element by their option values."
1117    )]
1118    async fn select_option(
1119        &self,
1120        Parameters(params): Parameters<SelectOptionParams>,
1121    ) -> CallToolResult {
1122        let values_json =
1123            serde_json::to_string(&params.values).unwrap_or_else(|_| "[]".to_string());
1124        let code = format!(
1125            "return window.__VICTAURI__?.selectOption({}, {})",
1126            js_string(&params.ref_id),
1127            values_json
1128        );
1129        match self
1130            .eval_with_return(&code, params.webview_label.as_deref())
1131            .await
1132        {
1133            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1134            Err(e) => tool_error(e),
1135        }
1136    }
1137
1138    #[tool(
1139        description = "Scroll to an element by ref handle ID (scrolls into view), or to absolute page coordinates if no ref given."
1140    )]
1141    async fn scroll_to(&self, Parameters(params): Parameters<ScrollToParams>) -> CallToolResult {
1142        let ref_arg = params
1143            .ref_id
1144            .as_ref()
1145            .map(|r| js_string(r))
1146            .unwrap_or_else(|| "null".to_string());
1147        let x = params.x.unwrap_or(0.0);
1148        let y = params.y.unwrap_or(0.0);
1149        let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1150        match self
1151            .eval_with_return(&code, params.webview_label.as_deref())
1152            .await
1153        {
1154            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1155            Err(e) => tool_error(e),
1156        }
1157    }
1158
1159    #[tool(description = "Focus an element by its ref handle ID.")]
1160    async fn focus_element(
1161        &self,
1162        Parameters(params): Parameters<FocusElementParams>,
1163    ) -> CallToolResult {
1164        let code = format!(
1165            "return window.__VICTAURI__?.focusElement({})",
1166            js_string(&params.ref_id)
1167        );
1168        match self
1169            .eval_with_return(&code, params.webview_label.as_deref())
1170            .await
1171        {
1172            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1173            Err(e) => tool_error(e),
1174        }
1175    }
1176
1177    // ── Network Monitoring ──────────────────────────────────────────────────
1178
1179    #[tool(
1180        description = "Get intercepted network requests (fetch and XMLHttpRequest). Filter by URL substring and limit the number of results."
1181    )]
1182    async fn get_network_log(
1183        &self,
1184        Parameters(params): Parameters<NetworkLogParams>,
1185    ) -> CallToolResult {
1186        let filter_arg = params
1187            .filter
1188            .as_ref()
1189            .map(|f| js_string(f))
1190            .unwrap_or_else(|| "null".to_string());
1191        let limit_arg = params
1192            .limit
1193            .map(|l| l.to_string())
1194            .unwrap_or_else(|| "null".to_string());
1195        let code = format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1196        match self
1197            .eval_with_return(&code, params.webview_label.as_deref())
1198            .await
1199        {
1200            Ok(result) => self.redact_result(result),
1201            Err(e) => tool_error(e),
1202        }
1203    }
1204
1205    // ── Storage ─────────────────────────────────────────────────────────────
1206
1207    #[tool(
1208        description = "Read from localStorage or sessionStorage. Returns all entries if no key is specified, or the value for a specific key."
1209    )]
1210    async fn get_storage(
1211        &self,
1212        Parameters(params): Parameters<GetStorageParams>,
1213    ) -> CallToolResult {
1214        let method = match params.storage_type.as_str() {
1215            "session" => "getSessionStorage",
1216            _ => "getLocalStorage",
1217        };
1218        let key_arg = params
1219            .key
1220            .as_ref()
1221            .map(|k| js_string(k))
1222            .unwrap_or_default();
1223        let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1224        match self
1225            .eval_with_return(&code, params.webview_label.as_deref())
1226            .await
1227        {
1228            Ok(result) => self.redact_result(result),
1229            Err(e) => tool_error(e),
1230        }
1231    }
1232
1233    #[tool(description = "Set a value in localStorage or sessionStorage.")]
1234    async fn set_storage(
1235        &self,
1236        Parameters(params): Parameters<SetStorageParams>,
1237    ) -> CallToolResult {
1238        if !self.state.privacy.is_tool_enabled("set_storage") {
1239            return tool_disabled("set_storage");
1240        }
1241        let method = match params.storage_type.as_str() {
1242            "session" => "setSessionStorage",
1243            _ => "setLocalStorage",
1244        };
1245        let value_json =
1246            serde_json::to_string(&params.value).unwrap_or_else(|_| "null".to_string());
1247        let code = format!(
1248            "return window.__VICTAURI__?.{method}({}, {value_json})",
1249            js_string(&params.key)
1250        );
1251        match self
1252            .eval_with_return(&code, params.webview_label.as_deref())
1253            .await
1254        {
1255            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1256            Err(e) => tool_error(e),
1257        }
1258    }
1259
1260    #[tool(description = "Delete a key from localStorage or sessionStorage.")]
1261    async fn delete_storage(
1262        &self,
1263        Parameters(params): Parameters<DeleteStorageParams>,
1264    ) -> CallToolResult {
1265        if !self.state.privacy.is_tool_enabled("delete_storage") {
1266            return tool_disabled("delete_storage");
1267        }
1268        let method = match params.storage_type.as_str() {
1269            "session" => "deleteSessionStorage",
1270            _ => "deleteLocalStorage",
1271        };
1272        let code = format!(
1273            "return window.__VICTAURI__?.{method}({})",
1274            js_string(&params.key)
1275        );
1276        match self
1277            .eval_with_return(&code, params.webview_label.as_deref())
1278            .await
1279        {
1280            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1281            Err(e) => tool_error(e),
1282        }
1283    }
1284
1285    #[tool(description = "Get all cookies visible to the webview document.")]
1286    async fn get_cookies(
1287        &self,
1288        Parameters(params): Parameters<GetCookiesParams>,
1289    ) -> CallToolResult {
1290        let code = "return window.__VICTAURI__?.getCookies()";
1291        match self
1292            .eval_with_return(code, params.webview_label.as_deref())
1293            .await
1294        {
1295            Ok(result) => self.redact_result(result),
1296            Err(e) => tool_error(e),
1297        }
1298    }
1299
1300    // ── Navigation ──────────────────────────────────────────────────────────
1301
1302    #[tool(
1303        description = "Get the navigation history log — tracks pushState, replaceState, popstate, hashchange, and the initial page load."
1304    )]
1305    async fn get_navigation_log(
1306        &self,
1307        Parameters(params): Parameters<NavigationLogParams>,
1308    ) -> CallToolResult {
1309        let code = "return window.__VICTAURI__?.getNavigationLog()";
1310        match self
1311            .eval_with_return(code, params.webview_label.as_deref())
1312            .await
1313        {
1314            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1315            Err(e) => tool_error(e),
1316        }
1317    }
1318
1319    #[tool(
1320        description = "Navigate the webview to a URL. Blocks javascript:, data:, and vbscript: URLs."
1321    )]
1322    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1323        if !self.state.privacy.is_tool_enabled("navigate") {
1324            return tool_disabled("navigate");
1325        }
1326        if let Err(e) = validate_url(&params.url) {
1327            return tool_error(e);
1328        }
1329        let code = format!(
1330            "return window.__VICTAURI__?.navigate({})",
1331            js_string(&params.url)
1332        );
1333        match self
1334            .eval_with_return(&code, params.webview_label.as_deref())
1335            .await
1336        {
1337            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1338            Err(e) => tool_error(e),
1339        }
1340    }
1341
1342    #[tool(description = "Navigate back in the webview's browser history.")]
1343    async fn navigate_back(
1344        &self,
1345        Parameters(params): Parameters<NavigationLogParams>,
1346    ) -> CallToolResult {
1347        let code = "return window.__VICTAURI__?.navigateBack()";
1348        match self
1349            .eval_with_return(code, params.webview_label.as_deref())
1350            .await
1351        {
1352            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1353            Err(e) => tool_error(e),
1354        }
1355    }
1356
1357    // ── Dialogs ─────────────────────────────────────────────────────────────
1358
1359    #[tool(
1360        description = "Get captured dialog events (alert, confirm, prompt). Dialogs are auto-handled: alert is accepted, confirm returns true, prompt returns default value."
1361    )]
1362    async fn get_dialog_log(
1363        &self,
1364        Parameters(params): Parameters<DialogLogParams>,
1365    ) -> CallToolResult {
1366        let code = "return window.__VICTAURI__?.getDialogLog()";
1367        match self
1368            .eval_with_return(code, params.webview_label.as_deref())
1369            .await
1370        {
1371            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1372            Err(e) => tool_error(e),
1373        }
1374    }
1375
1376    #[tool(
1377        description = "Configure automatic responses for browser dialogs. Types: alert, confirm, prompt. Actions: accept, dismiss. For prompt dialogs, optionally set the response text."
1378    )]
1379    async fn set_dialog_response(
1380        &self,
1381        Parameters(params): Parameters<SetDialogResponseParams>,
1382    ) -> CallToolResult {
1383        if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1384            return tool_disabled("set_dialog_response");
1385        }
1386        let text_arg = params
1387            .text
1388            .as_ref()
1389            .map(|t| js_string(t))
1390            .unwrap_or_else(|| "undefined".to_string());
1391        let code = format!(
1392            "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1393            js_string(&params.dialog_type),
1394            js_string(&params.action)
1395        );
1396        match self
1397            .eval_with_return(&code, params.webview_label.as_deref())
1398            .await
1399        {
1400            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1401            Err(e) => tool_error(e),
1402        }
1403    }
1404
1405    // ── Wait ────────────────────────────────────────────────────────────────
1406
1407    #[tool(
1408        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)."
1409    )]
1410    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
1411        let value = params
1412            .value
1413            .as_ref()
1414            .map(|v| js_string(v))
1415            .unwrap_or_else(|| "null".to_string());
1416        let timeout_ms = params.timeout_ms.unwrap_or(10000);
1417        let poll = params.poll_ms.unwrap_or(200);
1418        let code = format!(
1419            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
1420            js_string(&params.condition)
1421        );
1422        // Give the Rust-side eval timeout extra headroom beyond the JS-side
1423        // polling timeout so the JS promise has time to resolve or reject
1424        // before we forcibly cancel it.
1425        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
1426        match self
1427            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
1428            .await
1429        {
1430            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1431            Err(e) => tool_error(e),
1432        }
1433    }
1434
1435    // ── Window Management ───────────────────────────────────────────────────
1436
1437    #[tool(
1438        description = "Manage a window: minimize, unminimize, maximize, unmaximize, close, focus, show, hide, fullscreen, unfullscreen, always_on_top, not_always_on_top."
1439    )]
1440    async fn manage_window(
1441        &self,
1442        Parameters(params): Parameters<ManageWindowParams>,
1443    ) -> CallToolResult {
1444        match self
1445            .bridge
1446            .manage_window(params.label.as_deref(), &params.action)
1447        {
1448            Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1449            Err(e) => tool_error(e),
1450        }
1451    }
1452
1453    #[tool(description = "Resize a window to the specified width and height in logical pixels.")]
1454    async fn resize_window(
1455        &self,
1456        Parameters(params): Parameters<ResizeWindowParams>,
1457    ) -> CallToolResult {
1458        match self
1459            .bridge
1460            .resize_window(params.label.as_deref(), params.width, params.height)
1461        {
1462            Ok(()) => {
1463                let result =
1464                    serde_json::json!({"ok": true, "width": params.width, "height": params.height});
1465                CallToolResult::success(vec![Content::text(result.to_string())])
1466            }
1467            Err(e) => tool_error(e),
1468        }
1469    }
1470
1471    #[tool(
1472        description = "Move a window to the specified screen position (x, y) in logical pixels."
1473    )]
1474    async fn move_window(
1475        &self,
1476        Parameters(params): Parameters<MoveWindowParams>,
1477    ) -> CallToolResult {
1478        match self
1479            .bridge
1480            .move_window(params.label.as_deref(), params.x, params.y)
1481        {
1482            Ok(()) => {
1483                let result = serde_json::json!({"ok": true, "x": params.x, "y": params.y});
1484                CallToolResult::success(vec![Content::text(result.to_string())])
1485            }
1486            Err(e) => tool_error(e),
1487        }
1488    }
1489
1490    #[tool(description = "Set the title of a window.")]
1491    async fn set_window_title(
1492        &self,
1493        Parameters(params): Parameters<SetWindowTitleParams>,
1494    ) -> CallToolResult {
1495        match self
1496            .bridge
1497            .set_window_title(params.label.as_deref(), &params.title)
1498        {
1499            Ok(()) => {
1500                let result = serde_json::json!({"ok": true, "title": params.title});
1501                CallToolResult::success(vec![Content::text(result.to_string())])
1502            }
1503            Err(e) => tool_error(e),
1504        }
1505    }
1506
1507    // ── Phase 8: Deep Introspection ─────────────────────────────────────────
1508
1509    #[tool(
1510        description = "Get computed CSS styles for an element. Returns key properties by default, or specific properties if listed."
1511    )]
1512    async fn get_styles(&self, Parameters(params): Parameters<GetStylesParams>) -> CallToolResult {
1513        let props_arg = match &params.properties {
1514            Some(props) => {
1515                let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1516                format!("[{}]", arr.join(","))
1517            }
1518            None => "null".to_string(),
1519        };
1520        let code = format!(
1521            "return window.__VICTAURI__?.getStyles({}, {})",
1522            js_string(&params.ref_id),
1523            props_arg
1524        );
1525        match self
1526            .eval_with_return(&code, params.webview_label.as_deref())
1527            .await
1528        {
1529            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1530            Err(e) => tool_error(e),
1531        }
1532    }
1533
1534    #[tool(
1535        description = "Get precise bounding boxes with CSS box model (margin, padding, border) for one or more elements."
1536    )]
1537    async fn get_bounding_boxes(
1538        &self,
1539        Parameters(params): Parameters<GetBoundingBoxesParams>,
1540    ) -> CallToolResult {
1541        let refs: Vec<String> = params.ref_ids.iter().map(|r| js_string(r)).collect();
1542        let code = format!(
1543            "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1544            refs.join(",")
1545        );
1546        match self
1547            .eval_with_return(&code, params.webview_label.as_deref())
1548            .await
1549        {
1550            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1551            Err(e) => tool_error(e),
1552        }
1553    }
1554
1555    #[tool(
1556        description = "Draw a colored overlay on an element for visual debugging. The overlay is fixed-position and non-interactive."
1557    )]
1558    async fn highlight_element(
1559        &self,
1560        Parameters(params): Parameters<HighlightElementParams>,
1561    ) -> CallToolResult {
1562        let color_arg = match &params.color {
1563            Some(c) => match sanitize_css_color(c) {
1564                Ok(safe) => format!("\"{}\"", safe),
1565                Err(e) => return tool_error(e),
1566            },
1567            None => "null".to_string(),
1568        };
1569        let label_arg = match &params.label {
1570            Some(l) => js_string(l),
1571            None => "null".to_string(),
1572        };
1573        let code = format!(
1574            "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1575            js_string(&params.ref_id),
1576            color_arg,
1577            label_arg
1578        );
1579        match self
1580            .eval_with_return(&code, params.webview_label.as_deref())
1581            .await
1582        {
1583            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1584            Err(e) => tool_error(e),
1585        }
1586    }
1587
1588    #[tool(description = "Remove all debug highlight overlays from the page.")]
1589    async fn clear_highlights(
1590        &self,
1591        Parameters(params): Parameters<ClearHighlightsParams>,
1592    ) -> CallToolResult {
1593        let code = "return window.__VICTAURI__?.clearHighlights()";
1594        match self
1595            .eval_with_return(code, params.webview_label.as_deref())
1596            .await
1597        {
1598            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1599            Err(e) => tool_error(e),
1600        }
1601    }
1602
1603    #[tool(
1604        description = "Inject custom CSS into the page. Replaces any previously injected CSS. Useful for debugging layout issues or prototyping style changes."
1605    )]
1606    async fn inject_css(&self, Parameters(params): Parameters<InjectCssParams>) -> CallToolResult {
1607        if !self.state.privacy.is_tool_enabled("inject_css") {
1608            return tool_disabled("inject_css");
1609        }
1610        let code = format!(
1611            "return window.__VICTAURI__?.injectCss({})",
1612            js_string(&params.css)
1613        );
1614        match self
1615            .eval_with_return(&code, params.webview_label.as_deref())
1616            .await
1617        {
1618            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1619            Err(e) => tool_error(e),
1620        }
1621    }
1622
1623    #[tool(description = "Remove previously injected CSS from the page.")]
1624    async fn remove_injected_css(
1625        &self,
1626        Parameters(params): Parameters<RemoveInjectedCssParams>,
1627    ) -> CallToolResult {
1628        let code = "return window.__VICTAURI__?.removeInjectedCss()";
1629        match self
1630            .eval_with_return(code, params.webview_label.as_deref())
1631            .await
1632        {
1633            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1634            Err(e) => tool_error(e),
1635        }
1636    }
1637
1638    #[tool(
1639        description = "Run a comprehensive accessibility audit. Checks for missing alt text, unlabeled form inputs, empty buttons/links, heading hierarchy, color contrast, ARIA role validity, and more. Returns violations and warnings with severity levels."
1640    )]
1641    async fn audit_accessibility(
1642        &self,
1643        Parameters(params): Parameters<AuditAccessibilityParams>,
1644    ) -> CallToolResult {
1645        let code = "return window.__VICTAURI__?.auditAccessibility()";
1646        match self
1647            .eval_with_return(code, params.webview_label.as_deref())
1648            .await
1649        {
1650            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1651            Err(e) => tool_error(e),
1652        }
1653    }
1654
1655    #[tool(
1656        description = "Get performance metrics: navigation timing, resource loading summary, paint timing, JS heap usage, long task count, and DOM statistics."
1657    )]
1658    async fn get_performance_metrics(
1659        &self,
1660        Parameters(params): Parameters<GetPerformanceMetricsParams>,
1661    ) -> CallToolResult {
1662        let code = "return window.__VICTAURI__?.getPerformanceMetrics()";
1663        match self
1664            .eval_with_return(code, params.webview_label.as_deref())
1665            .await
1666        {
1667            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1668            Err(e) => tool_error(e),
1669        }
1670    }
1671
1672    #[tool(
1673        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."
1674    )]
1675    async fn get_plugin_info(&self) -> CallToolResult {
1676        let disabled: Vec<&str> = self
1677            .state
1678            .privacy
1679            .disabled_tools
1680            .iter()
1681            .map(|s| s.as_str())
1682            .collect();
1683        let blocklist: Vec<&str> = self
1684            .state
1685            .privacy
1686            .command_blocklist
1687            .iter()
1688            .map(|s| s.as_str())
1689            .collect();
1690        let allowlist: Option<Vec<&str>> = self
1691            .state
1692            .privacy
1693            .command_allowlist
1694            .as_ref()
1695            .map(|s| s.iter().map(|s| s.as_str()).collect());
1696        let all_tools = Self::tool_router().list_all();
1697        let enabled_tools: Vec<&str> = all_tools
1698            .iter()
1699            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1700            .map(|t| t.name.as_ref())
1701            .collect();
1702
1703        let result = serde_json::json!({
1704            "version": env!("CARGO_PKG_VERSION"),
1705            "bridge_version": BRIDGE_VERSION,
1706            "port": self.state.port,
1707            "tools": {
1708                "total": all_tools.len(),
1709                "enabled": enabled_tools.len(),
1710                "enabled_list": enabled_tools,
1711                "disabled_list": disabled,
1712            },
1713            "commands": {
1714                "allowlist": allowlist,
1715                "blocklist": blocklist,
1716            },
1717            "privacy": {
1718                "redaction_enabled": self.state.privacy.redaction_enabled,
1719            },
1720            "capacities": {
1721                "event_log": self.state.event_log.capacity(),
1722                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1723            },
1724            "registered_commands": self.state.registry.count(),
1725            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1726            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1727        });
1728        match serde_json::to_string_pretty(&result) {
1729            Ok(json) => CallToolResult::success(vec![Content::text(json)]),
1730            Err(e) => tool_error(e.to_string()),
1731        }
1732    }
1733}
1734
1735impl VictauriMcpHandler {
1736    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1737        Self {
1738            state,
1739            bridge,
1740            subscriptions: Arc::new(Mutex::new(HashSet::new())),
1741            bridge_checked: Arc::new(AtomicBool::new(false)),
1742        }
1743    }
1744
1745    fn redact_result(&self, output: String) -> CallToolResult {
1746        let redacted = self.state.privacy.redact_output(&output);
1747        CallToolResult::success(vec![Content::text(redacted)])
1748    }
1749
1750    async fn eval_with_return(
1751        &self,
1752        code: &str,
1753        webview_label: Option<&str>,
1754    ) -> Result<String, String> {
1755        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1756            .await
1757    }
1758
1759    async fn eval_with_return_timeout(
1760        &self,
1761        code: &str,
1762        webview_label: Option<&str>,
1763        timeout: std::time::Duration,
1764    ) -> Result<String, String> {
1765        let id = uuid::Uuid::new_v4().to_string();
1766        let (tx, rx) = tokio::sync::oneshot::channel();
1767
1768        {
1769            let mut pending = self.state.pending_evals.lock().await;
1770            if pending.len() >= MAX_PENDING_EVALS {
1771                return Err(format!(
1772                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1773                ));
1774            }
1775            pending.insert(id.clone(), tx);
1776        }
1777
1778        // Auto-prepend `return` so bare expressions produce a value.
1779        // Only skip for code that starts with a statement keyword where
1780        // prepending `return` would be a syntax error.
1781        let code = code.trim();
1782        let needs_return = !code.starts_with("return ")
1783            && !code.starts_with("return;")
1784            && !code.starts_with('{')
1785            && !code.starts_with("if ")
1786            && !code.starts_with("if(")
1787            && !code.starts_with("for ")
1788            && !code.starts_with("for(")
1789            && !code.starts_with("while ")
1790            && !code.starts_with("while(")
1791            && !code.starts_with("switch ")
1792            && !code.starts_with("try ")
1793            && !code.starts_with("const ")
1794            && !code.starts_with("let ")
1795            && !code.starts_with("var ")
1796            && !code.starts_with("function ")
1797            && !code.starts_with("class ")
1798            && !code.starts_with("throw ");
1799        let code = if needs_return {
1800            format!("return {code}")
1801        } else {
1802            code.to_string()
1803        };
1804
1805        let inject = format!(
1806            r#"
1807            (async () => {{
1808                try {{
1809                    const __result = await (async () => {{ {code} }})();
1810                    await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1811                        id: '{id}',
1812                        result: JSON.stringify(__result)
1813                    }});
1814                }} catch (e) {{
1815                    await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1816                        id: '{id}',
1817                        result: JSON.stringify({{ __error: e.message }})
1818                    }});
1819                }}
1820            }})();
1821            "#
1822        );
1823
1824        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
1825            self.state.pending_evals.lock().await.remove(&id);
1826            return Err(format!("eval injection failed: {e}"));
1827        }
1828
1829        match tokio::time::timeout(timeout, rx).await {
1830            Ok(Ok(result)) => {
1831                self.check_bridge_version_once();
1832                Ok(result)
1833            }
1834            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
1835            Err(_) => {
1836                self.state.pending_evals.lock().await.remove(&id);
1837                Err(format!("eval timed out after {}s", timeout.as_secs()))
1838            }
1839        }
1840    }
1841
1842    fn check_bridge_version_once(&self) {
1843        if self.bridge_checked.swap(true, Ordering::Relaxed) {
1844            return;
1845        }
1846        let handler = self.clone();
1847        tokio::spawn(async move {
1848            match handler
1849                .eval_with_return_timeout(
1850                    "window.__VICTAURI__?.version",
1851                    None,
1852                    std::time::Duration::from_secs(5),
1853                )
1854                .await
1855            {
1856                Ok(v) => {
1857                    let v = v.trim_matches('"');
1858                    if v != BRIDGE_VERSION {
1859                        tracing::warn!(
1860                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
1861                        );
1862                    } else {
1863                        tracing::debug!("Bridge version verified: {v}");
1864                    }
1865                }
1866                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
1867            }
1868        });
1869    }
1870}
1871
1872const SERVER_INSTRUCTIONS: &str = "Victauri gives you X-ray vision and hands inside a running Tauri application. \
1873You can evaluate JS, snapshot the DOM, interact with elements (click, double-click, \
1874hover, fill, type, select, scroll, focus), press keys, invoke Tauri commands, \
1875capture screenshots, manage windows (minimize, maximize, resize, move, close), \
1876view IPC and network traffic, read/write browser storage, track navigation history, \
1877handle dialogs, wait for conditions, search the command registry, monitor process memory, \
1878record and replay sessions, inspect computed CSS styles, measure element bounding boxes, \
1879draw debug overlays on elements, inject custom CSS, run accessibility audits (alt text, \
1880labels, contrast, ARIA, heading hierarchy), get performance metrics (navigation timing, \
1881resource loading, JS heap, long tasks, DOM stats), and subscribe to live resource \
1882streams — all through MCP.";
1883
1884impl ServerHandler for VictauriMcpHandler {
1885    fn get_info(&self) -> ServerInfo {
1886        ServerInfo::new(
1887            ServerCapabilities::builder()
1888                .enable_tools()
1889                .enable_resources()
1890                .enable_resources_subscribe()
1891                .build(),
1892        )
1893        .with_instructions(SERVER_INSTRUCTIONS)
1894    }
1895
1896    async fn list_tools(
1897        &self,
1898        _request: Option<PaginatedRequestParams>,
1899        _context: RequestContext<RoleServer>,
1900    ) -> Result<ListToolsResult, ErrorData> {
1901        let all_tools = Self::tool_router().list_all();
1902        let filtered: Vec<Tool> = all_tools
1903            .into_iter()
1904            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1905            .collect();
1906        Ok(ListToolsResult {
1907            tools: filtered,
1908            ..Default::default()
1909        })
1910    }
1911
1912    async fn call_tool(
1913        &self,
1914        request: CallToolRequestParams,
1915        context: RequestContext<RoleServer>,
1916    ) -> Result<CallToolResult, ErrorData> {
1917        let tool_name: String = request.name.as_ref().to_owned();
1918        if !self.state.privacy.is_tool_enabled(&tool_name) {
1919            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
1920            return Ok(tool_disabled(&tool_name));
1921        }
1922        self.state
1923            .tool_invocations
1924            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1925        let start = std::time::Instant::now();
1926        tracing::debug!(tool = %tool_name, "tool invocation started");
1927        let ctx = ToolCallContext::new(self, request, context);
1928        let result = Self::tool_router().call(ctx).await;
1929        let elapsed = start.elapsed();
1930        tracing::debug!(
1931            tool = %tool_name,
1932            elapsed_ms = elapsed.as_millis() as u64,
1933            is_error = result.as_ref().map(|r| r.is_error.unwrap_or(false)).unwrap_or(true),
1934            "tool invocation completed"
1935        );
1936        result
1937    }
1938
1939    fn get_tool(&self, name: &str) -> Option<Tool> {
1940        if !self.state.privacy.is_tool_enabled(name) {
1941            return None;
1942        }
1943        Self::tool_router().get(name).cloned()
1944    }
1945
1946    async fn list_resources(
1947        &self,
1948        _request: Option<PaginatedRequestParams>,
1949        _context: RequestContext<RoleServer>,
1950    ) -> Result<ListResourcesResult, ErrorData> {
1951        Ok(ListResourcesResult {
1952            resources: vec![
1953                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
1954                    .with_description(
1955                        "Live IPC call log — all commands invoked between frontend and backend",
1956                    )
1957                    .with_mime_type("application/json")
1958                    .no_annotation(),
1959                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
1960                    .with_description(
1961                        "Current state of all Tauri windows — position, size, visibility, focus",
1962                    )
1963                    .with_mime_type("application/json")
1964                    .no_annotation(),
1965                RawResource::new(RESOURCE_URI_STATE, "state")
1966                    .with_description(
1967                        "Victauri plugin state — event count, registered commands, memory stats",
1968                    )
1969                    .with_mime_type("application/json")
1970                    .no_annotation(),
1971            ],
1972            ..Default::default()
1973        })
1974    }
1975
1976    async fn read_resource(
1977        &self,
1978        request: ReadResourceRequestParams,
1979        _context: RequestContext<RoleServer>,
1980    ) -> Result<ReadResourceResult, ErrorData> {
1981        let uri = &request.uri;
1982        let json = match uri.as_str() {
1983            RESOURCE_URI_IPC_LOG => {
1984                let calls = self.state.event_log.ipc_calls();
1985                serde_json::to_string_pretty(&calls)
1986                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1987            }
1988            RESOURCE_URI_WINDOWS => {
1989                let states = self.bridge.get_window_states(None);
1990                serde_json::to_string_pretty(&states)
1991                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1992            }
1993            RESOURCE_URI_STATE => {
1994                let state_json = serde_json::json!({
1995                    "events_captured": self.state.event_log.len(),
1996                    "commands_registered": self.state.registry.count(),
1997                    "memory": crate::memory::current_stats(),
1998                    "port": self.state.port,
1999                });
2000                serde_json::to_string_pretty(&state_json)
2001                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2002            }
2003            _ => {
2004                return Err(ErrorData::resource_not_found(
2005                    format!("unknown resource: {uri}"),
2006                    None,
2007                ));
2008            }
2009        };
2010
2011        Ok(ReadResourceResult::new(vec![ResourceContents::text(
2012            json, uri,
2013        )]))
2014    }
2015
2016    async fn subscribe(
2017        &self,
2018        request: SubscribeRequestParams,
2019        _context: RequestContext<RoleServer>,
2020    ) -> Result<(), ErrorData> {
2021        let uri = &request.uri;
2022        match uri.as_str() {
2023            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
2024                self.subscriptions.lock().await.insert(uri.clone());
2025                tracing::info!("Client subscribed to resource: {uri}");
2026                Ok(())
2027            }
2028            _ => Err(ErrorData::resource_not_found(
2029                format!("unknown resource: {uri}"),
2030                None,
2031            )),
2032        }
2033    }
2034
2035    async fn unsubscribe(
2036        &self,
2037        request: UnsubscribeRequestParams,
2038        _context: RequestContext<RoleServer>,
2039    ) -> Result<(), ErrorData> {
2040        self.subscriptions.lock().await.remove(&request.uri);
2041        tracing::info!("Client unsubscribed from resource: {}", request.uri);
2042        Ok(())
2043    }
2044}
2045
2046fn tool_error(msg: impl Into<String>) -> CallToolResult {
2047    let mut result = CallToolResult::success(vec![Content::text(msg)]);
2048    result.is_error = Some(true);
2049    result
2050}
2051
2052fn tool_disabled(name: &str) -> CallToolResult {
2053    tool_error(format!(
2054        "tool '{name}' is disabled by privacy configuration"
2055    ))
2056}
2057
2058fn validate_url(url: &str) -> Result<(), String> {
2059    let trimmed: String = url.chars().filter(|c| !c.is_control()).collect();
2060    match url::Url::parse(&trimmed) {
2061        Ok(parsed) => match parsed.scheme() {
2062            "http" | "https" | "file" => Ok(()),
2063            scheme => Err(format!(
2064                "scheme '{scheme}' is not allowed; use http, https, or file"
2065            )),
2066        },
2067        Err(e) => Err(format!("invalid URL: {e}")),
2068    }
2069}
2070
2071fn sanitize_css_color(color: &str) -> Result<String, String> {
2072    let s = color.trim();
2073    if s.len() > 100 {
2074        return Err("CSS color value too long".to_string());
2075    }
2076    // Reject CSS escape sequences (\XX hex escapes)
2077    if s.contains('\\') {
2078        return Err("CSS escape sequences not allowed in color values".to_string());
2079    }
2080    let valid = s
2081        .chars()
2082        .all(|c| c.is_alphanumeric() || matches!(c, '#' | '(' | ')' | ',' | '.' | ' ' | '%' | '-'));
2083    if !valid {
2084        return Err("invalid characters in CSS color value".to_string());
2085    }
2086    if s.contains(';')
2087        || s.contains('{')
2088        || s.contains('}')
2089        || s.to_lowercase().contains("url(")
2090        || s.to_lowercase().contains("expression(")
2091        || s.to_lowercase().contains("import")
2092    {
2093        return Err("invalid CSS color value".to_string());
2094    }
2095    Ok(s.to_string())
2096}
2097
2098// ── Server startup ───────────────────────────────────────────────────────────
2099
2100pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
2101    build_app_with_options(state, bridge, None)
2102}
2103
2104pub fn build_app_with_options(
2105    state: Arc<VictauriState>,
2106    bridge: Arc<dyn WebviewBridge>,
2107    auth_token: Option<String>,
2108) -> axum::Router {
2109    let handler = VictauriMcpHandler::new(state.clone(), bridge);
2110
2111    let mcp_service = StreamableHttpService::new(
2112        move || Ok(handler.clone()),
2113        Arc::new(LocalSessionManager::default()),
2114        StreamableHttpServerConfig::default(),
2115    );
2116
2117    let auth_state = Arc::new(crate::auth::AuthState {
2118        token: auth_token.clone(),
2119    });
2120    let health_state = state.clone();
2121    let info_state = state.clone();
2122    let info_auth = auth_token.is_some();
2123
2124    let privacy_enabled = !state.privacy.disabled_tools.is_empty()
2125        || state.privacy.command_allowlist.is_some()
2126        || !state.privacy.command_blocklist.is_empty()
2127        || state.privacy.redaction_enabled;
2128
2129    let mut router = axum::Router::new()
2130        .route_service("/mcp", mcp_service)
2131        .route(
2132            "/info",
2133            axum::routing::get(move || {
2134                let s = info_state.clone();
2135                async move {
2136                    axum::Json(serde_json::json!({
2137                        "name": "victauri",
2138                        "version": env!("CARGO_PKG_VERSION"),
2139                        "protocol": "mcp",
2140                        "commands_registered": s.registry.count(),
2141                        "events_captured": s.event_log.len(),
2142                        "port": s.port,
2143                        "auth_required": info_auth,
2144                        "privacy_mode": privacy_enabled,
2145                    }))
2146                }
2147            }),
2148        );
2149
2150    if auth_token.is_some() {
2151        router = router.layer(axum::middleware::from_fn_with_state(
2152            auth_state,
2153            crate::auth::require_auth,
2154        ));
2155    }
2156
2157    let rate_limiter = crate::auth::default_rate_limiter();
2158    router = router.layer(axum::middleware::from_fn_with_state(
2159        rate_limiter,
2160        crate::auth::rate_limit,
2161    ));
2162
2163    router
2164        .route(
2165            "/health",
2166            axum::routing::get(move || {
2167                let s = health_state.clone();
2168                async move {
2169                    axum::Json(serde_json::json!({
2170                        "status": "ok",
2171                        "uptime_secs": s.started_at.elapsed().as_secs(),
2172                        "events_captured": s.event_log.len(),
2173                        "commands_registered": s.registry.count(),
2174                        "memory": crate::memory::current_stats(),
2175                    }))
2176                }
2177            }),
2178        )
2179        .layer(axum::middleware::from_fn(crate::auth::security_headers))
2180        .layer(axum::middleware::from_fn(crate::auth::origin_guard))
2181        .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
2182}
2183
2184pub mod tests_support {
2185    pub fn get_memory_stats() -> serde_json::Value {
2186        crate::memory::current_stats()
2187    }
2188}
2189
2190pub async fn start_server<R: Runtime>(
2191    app_handle: tauri::AppHandle<R>,
2192    state: Arc<VictauriState>,
2193    port: u16,
2194    shutdown_rx: tokio::sync::watch::Receiver<bool>,
2195) -> anyhow::Result<()> {
2196    start_server_with_options(app_handle, state, port, None, shutdown_rx).await
2197}
2198
2199pub async fn start_server_with_options<R: Runtime>(
2200    app_handle: tauri::AppHandle<R>,
2201    state: Arc<VictauriState>,
2202    port: u16,
2203    auth_token: Option<String>,
2204    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
2205) -> anyhow::Result<()> {
2206    let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
2207    let app = build_app_with_options(state, bridge, auth_token);
2208
2209    let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await?;
2210    tracing::info!("Victauri MCP server listening on 127.0.0.1:{port}");
2211
2212    axum::serve(listener, app)
2213        .with_graceful_shutdown(async move {
2214            let _ = shutdown_rx.wait_for(|&v| v).await;
2215            tracing::info!("Victauri MCP server shutting down gracefully");
2216        })
2217        .await?;
2218    Ok(())
2219}
2220
2221#[cfg(test)]
2222mod tests {
2223    use super::*;
2224
2225    #[test]
2226    fn js_string_simple() {
2227        assert_eq!(js_string("hello"), "\"hello\"");
2228    }
2229
2230    #[test]
2231    fn js_string_single_quotes() {
2232        let result = js_string("it's a test");
2233        assert!(result.contains("it's a test"));
2234    }
2235
2236    #[test]
2237    fn js_string_double_quotes() {
2238        let result = js_string(r#"say "hello""#);
2239        assert!(result.contains(r#"\""#));
2240    }
2241
2242    #[test]
2243    fn js_string_backslashes() {
2244        let result = js_string(r"path\to\file");
2245        assert!(result.contains(r"\\"));
2246    }
2247
2248    #[test]
2249    fn js_string_newlines_and_tabs() {
2250        let result = js_string("line1\nline2\ttab");
2251        assert!(result.contains(r"\n"));
2252        assert!(result.contains(r"\t"));
2253        assert!(!result.contains('\n'));
2254    }
2255
2256    #[test]
2257    fn js_string_null_bytes() {
2258        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
2259        let result = js_string(&input);
2260        // serde_json escapes null bytes as 
2261        assert!(result.contains("\\u0000"));
2262        assert!(!result.contains('\0'));
2263    }
2264
2265    #[test]
2266    fn js_string_template_literal_injection() {
2267        let result = js_string("`${alert(1)}`");
2268        // Should not contain unescaped backticks that could break template literals
2269        // serde_json wraps in double quotes, so backticks are safe
2270        assert!(result.starts_with('"'));
2271        assert!(result.ends_with('"'));
2272    }
2273
2274    #[test]
2275    fn js_string_unicode_separators() {
2276        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
2277        // JSON strings per RFC 8259, and serde_json passes them through literally.
2278        // Since js_string is used inside JS double-quoted strings (not template
2279        // literals), they are safe in modern JS engines (ES2019+).
2280        let result = js_string("a\u{2028}b\u{2029}c");
2281        // Verify the string is valid JSON that round-trips correctly
2282        let decoded: String = serde_json::from_str(&result).unwrap();
2283        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
2284    }
2285
2286    #[test]
2287    fn js_string_empty() {
2288        assert_eq!(js_string(""), "\"\"");
2289    }
2290
2291    #[test]
2292    fn js_string_html_script_close() {
2293        // </script> in a JS string inside HTML could break out of script tags
2294        let result = js_string("</script><img onerror=alert(1)>");
2295        assert!(result.starts_with('"'));
2296        // The string is JSON-encoded; verify it round-trips safely
2297        let decoded: String = serde_json::from_str(&result).unwrap();
2298        assert_eq!(decoded, "</script><img onerror=alert(1)>");
2299    }
2300
2301    #[test]
2302    fn js_string_very_long() {
2303        let long = "a".repeat(100_000);
2304        let result = js_string(&long);
2305        assert!(result.len() >= 100_002); // quotes + content
2306    }
2307
2308    // ── URL validation tests ────────────────────────────────────────────────
2309
2310    #[test]
2311    fn url_allows_http() {
2312        assert!(validate_url("http://example.com").is_ok());
2313    }
2314
2315    #[test]
2316    fn url_allows_https() {
2317        assert!(validate_url("https://example.com/path?q=1").is_ok());
2318    }
2319
2320    #[test]
2321    fn url_allows_file() {
2322        assert!(validate_url("file:///tmp/test.html").is_ok());
2323    }
2324
2325    #[test]
2326    fn url_blocks_javascript() {
2327        assert!(validate_url("javascript:alert(1)").is_err());
2328    }
2329
2330    #[test]
2331    fn url_blocks_javascript_case_insensitive() {
2332        assert!(validate_url("JAVASCRIPT:alert(1)").is_err());
2333    }
2334
2335    #[test]
2336    fn url_blocks_data_scheme() {
2337        assert!(validate_url("data:text/html,<script>alert(1)</script>").is_err());
2338    }
2339
2340    #[test]
2341    fn url_blocks_vbscript() {
2342        assert!(validate_url("vbscript:MsgBox(1)").is_err());
2343    }
2344
2345    #[test]
2346    fn url_rejects_invalid() {
2347        assert!(validate_url("not a url at all").is_err());
2348    }
2349
2350    #[test]
2351    fn url_strips_control_chars() {
2352        // Control characters should be stripped, leaving a valid URL
2353        let input = format!("http://example{}com", '\0');
2354        assert!(validate_url(&input).is_ok());
2355    }
2356}