use axum::{ body::Body, extract::State, http::{header, Request, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::Arc; use tauri::AppHandle; use tokio::net::TcpListener; use tokio::sync::Mutex as AsyncMutex; use uuid::Uuid; use crate::browser::ProxySettings; use crate::cdp_target::{CdpError, CdpTarget}; use crate::cloud_auth::CLOUD_AUTH; use crate::group_manager::GROUP_MANAGER; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::settings_manager::SettingsManager; use crate::wayfern_terms::WayfernTermsManager; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpTool { pub name: String, pub description: String, pub input_schema: serde_json::Value, } /// JavaScript executed in the target page to enumerate visible interactive /// elements. Returns a JSON string `{elements, count, truncated}` where /// `elements` is the newline-joined labeled list. Live references are stashed /// on `window.__donut_interactive` so subsequent `click_by_index` / /// `type_by_index` calls can resolve `index → Element` without round-tripping /// a selector. `__MAX_CHARS__` is substituted at call time. const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => { const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt']; const MAX_CHARS = __MAX_CHARS__; const interactive = []; const lines = []; let truncated = false; let total = 0; const nodes = document.querySelectorAll(SELECTORS); for (const el of nodes) { if (el.disabled) continue; const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) continue; const style = window.getComputedStyle(el); if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue; const tag = el.tagName.toLowerCase(); const parts = []; for (const a of ATTRS) { const v = el.getAttribute(a); if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"'); } let text = ''; if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) { text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100); } const idx = interactive.length; const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + ''; if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; } total += line.length + 1; interactive.push(el); lines.push(line); } window.__donut_interactive = interactive; return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated }); })()"#; #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct McpRequest { jsonrpc: String, id: Option, method: String, params: Option, } const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "donut-browser"; const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Serialize)] pub struct McpResponse { jsonrpc: String, #[serde(skip_serializing_if = "Option::is_none")] id: Option, #[serde(skip_serializing_if = "Option::is_none")] result: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Debug, Serialize)] pub struct McpError { code: i32, message: String, } /// Surface a CDP failure to the agent with the reason intact. /// /// The distinction matters to whoever is on the other end: "the session is /// still provisioning" invites a retry in a few seconds, "you are signed out" /// does not, and flattening both into `-32000: something went wrong` is how an /// automation client ends up retrying a refusal forever. fn cdp_error(error: CdpError) -> McpError { McpError { code: -32000, message: error.to_string(), } } const DEFAULT_MCP_PORT: u16 = 51080; /// How long a keystroke waits for its acknowledgement before moving on. /// /// Generous enough to absorb a relayed round trip, short enough that a browser /// which stops answering does not leave the caller typing into a socket that /// will never reply. const KEYSTROKE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); struct McpSession { initialized: bool, } struct McpServerInner { app_handle: Option, token: Option, shutdown_tx: Option>, sessions: HashMap, } #[derive(Clone)] struct McpHttpState { server: &'static McpServer, token: String, } pub struct McpServer { inner: Arc>, is_running: AtomicBool, port: AtomicU16, } impl McpServer { fn new() -> Self { Self { inner: Arc::new(AsyncMutex::new(McpServerInner { app_handle: None, token: None, shutdown_tx: None, sessions: HashMap::new(), })), is_running: AtomicBool::new(false), port: AtomicU16::new(0), } } pub fn instance() -> &'static McpServer { &MCP_SERVER } pub fn is_running(&self) -> bool { self.is_running.load(Ordering::SeqCst) } /// Gate an MCP tool on a capability the caller already resolved (e.g. /// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate /// with enough state for support to diagnose, without leaking secrets. async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> { if !allowed { let summary = match CLOUD_AUTH.get_user().await { Some(state) => format!( "logged_in=true plan={} status={} period={:?}", state.user.plan, state.user.subscription_status, state.user.plan_period, ), None => "logged_in=false".to_string(), }; log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})"); return Err(McpError { code: -32000, message: format!("{feature} requires a plan that includes this feature"), }); } Ok(()) } pub fn get_port(&self) -> Option { let port = self.port.load(Ordering::SeqCst); if port > 0 { Some(port) } else { None } } pub async fn start(&self, app_handle: AppHandle) -> Result { if !WayfernTermsManager::instance().is_terms_accepted() { return Err(crate::backend_error("WAYFERN_TERMS_REQUIRED")); } if self.is_running() { return Err(crate::backend_error("MCP_SERVER_ALREADY_RUNNING")); } let settings_manager = SettingsManager::instance(); let settings = settings_manager .load_settings() .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; // Get or generate token let existing_token = settings_manager .get_mcp_token(&app_handle) .await .ok() .flatten(); let token = if let Some(t) = existing_token { t } else { settings_manager .generate_mcp_token(&app_handle) .await .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? }; // Determine port (use saved port, or try default, or random) let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT); let listener = self.bind_to_available_port(preferred_port).await?; let actual_port = listener .local_addr() .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .port(); // Save port if it changed if settings.mcp_port != Some(actual_port) { let mut new_settings = settings; new_settings.mcp_port = Some(actual_port); settings_manager .save_settings(&new_settings) .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; } // Store state let mut inner = self.inner.lock().await; inner.app_handle = Some(app_handle); inner.token = Some(token.clone()); // Create shutdown channel let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); inner.shutdown_tx = Some(shutdown_tx); self.port.store(actual_port, Ordering::SeqCst); self.is_running.store(true, Ordering::SeqCst); // Start HTTP server in background let http_state = McpHttpState { server: McpServer::instance(), token, }; tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx)); log::info!("[mcp] Server started on port {}", actual_port); Ok(actual_port) } async fn bind_to_available_port(&self, preferred: u16) -> Result { let addr = SocketAddr::from(([127, 0, 0, 1], preferred)); if let Ok(listener) = TcpListener::bind(addr).await { return Ok(listener); } for _ in 0..10 { let port = 51000 + (rand::random::() % 1000); let addr = SocketAddr::from(([127, 0, 0, 1], port)); if let Ok(listener) = TcpListener::bind(addr).await { return Ok(listener); } } Err(crate::backend_error("MCP_PORT_UNAVAILABLE")) } async fn run_http_server( listener: TcpListener, state: McpHttpState, shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { let app = Router::new() .route( "/mcp/{token}", post(Self::handle_mcp_post) .get(Self::handle_mcp_get) .delete(Self::handle_mcp_delete), ) .route( "/mcp", post(Self::handle_mcp_post) .get(Self::handle_mcp_get) .delete(Self::handle_mcp_delete), ) .route("/health", get(Self::handle_health)) .layer(middleware::from_fn_with_state( state.clone(), Self::auth_middleware, )) .with_state(state); let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0); let server = async move { log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port); if let Err(e) = axum::serve(listener, app).await { log::error!("[mcp] Server error: {}", e); } }; tokio::select! { _ = server => {}, _ = shutdown_rx => { log::info!("[mcp] Server shutting down"); }, } } async fn auth_middleware( State(state): State, req: Request, next: Next, ) -> Result { let path = req.uri().path(); if path == "/health" { return Ok(next.run(req).await); } // Check token from URL path: /mcp/{token} let path_token = path .strip_prefix("/mcp/") .filter(|t| !t.is_empty() && !t.contains('/')); // Check token from Authorization header let header_token = req .headers() .get(header::AUTHORIZATION) .and_then(|h| h.to_str().ok()) .and_then(|h| h.strip_prefix("Bearer ")); // Constant-time comparison to avoid leaking the token prefix via timing. use subtle::ConstantTimeEq; let expected = state.token.as_bytes(); let ct_eq = |t: Option<&str>| { t.is_some_and(|t| { let b = t.as_bytes(); b.len() == expected.len() && b.ct_eq(expected).into() }) }; let valid = ct_eq(path_token) || ct_eq(header_token); if !valid { return Err(StatusCode::UNAUTHORIZED); } Ok(next.run(req).await) } async fn handle_health() -> impl IntoResponse { Json(serde_json::json!({ "status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION, "protocolVersion": PROTOCOL_VERSION, })) } async fn handle_mcp_get() -> impl IntoResponse { // We don't support server-initiated SSE streams StatusCode::METHOD_NOT_ALLOWED } async fn handle_mcp_delete( State(state): State, req: Request, ) -> impl IntoResponse { let session_id = req .headers() .get("mcp-session-id") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); if let Some(sid) = session_id { let mut inner = state.server.inner.lock().await; inner.sessions.remove(&sid); log::info!("[mcp] Session terminated: {}", sid); } StatusCode::OK } async fn handle_mcp_post(State(state): State, req: Request) -> Response { let session_id = req .headers() .get("mcp-session-id") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); let body_bytes = match axum::body::to_bytes(req.into_body(), 1024 * 1024).await { Ok(b) => b, Err(_) => { return (StatusCode::BAD_REQUEST, "Invalid request body").into_response(); } }; let request: McpRequest = match serde_json::from_slice(&body_bytes) { Ok(r) => r, Err(_) => { return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(); } }; let is_notification = request.id.is_none(); let method = request.method.clone(); // Handle initialize (no session required) if method == "initialize" { let response = state.server.handle_initialize(request).await; match response { Ok((session_id, result)) => { let body = McpResponse { jsonrpc: "2.0".to_string(), id: Some(result.0), result: Some(result.1), error: None, }; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/json") .header("mcp-session-id", &session_id) .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap() } Err((id, error)) => { let body = McpResponse { jsonrpc: "2.0".to_string(), id: Some(id), result: None, error: Some(error), }; Json(body).into_response() } } } else if is_notification { // Notifications (like notifications/initialized) -> 202 Accepted if method == "notifications/initialized" { if let Some(sid) = &session_id { let mut inner = state.server.inner.lock().await; if let Some(session) = inner.sessions.get_mut(sid) { session.initialized = true; } } } StatusCode::ACCEPTED.into_response() } else { // Validate session exists if let Some(sid) = &session_id { let inner = state.server.inner.lock().await; if !inner.sessions.contains_key(sid) { return StatusCode::NOT_FOUND.into_response(); } } if Self::is_automation_tool_call(&request) { if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } = crate::automation_rate_limiter::check_automation_rate_limit().await { log::warn!( "[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s", retry_after_secs ); return ( StatusCode::TOO_MANY_REQUESTS, [(header::RETRY_AFTER, retry_after_secs.to_string())], "automation request rate limit exceeded", ) .into_response(); } } let response = state.server.handle_request(request).await; Json(response).into_response() } } fn is_automation_tool_call(request: &McpRequest) -> bool { if request.method != "tools/call" { return false; } let Some(tool_name) = request .params .as_ref() .and_then(|params| params.get("name")) .and_then(|name| name.as_str()) else { return false; }; matches!( tool_name, "run_profile" | "kill_profile" | "batch_run_profiles" | "batch_stop_profiles" | "start_sync_session" | "navigate" | "screenshot" | "evaluate_javascript" | "click_element" | "type_text" | "get_page_content" | "get_page_info" | "get_interactive_elements" | "click_by_index" | "type_by_index" // Starting a bot run leases a remote host for up to two hours and // spends the account's pooled remote-hour budget, which makes it the // most expensive tool here. Cancelling one reaches the same fleet, and // is metered alongside the remote-session stop it mirrors. // // Deliberately absent: set_cookie_bot_schedule and // delete_cookie_bot_schedule. They write one row in Donut cloud and // lease nothing; metering them would throttle an agent enrolling a // fleet of profiles, while the budget that actually guards the // hardware is spent per RUN and enforced server-side. | "run_cookie_bot_now" | "cancel_cookie_bot_run" // Leasing a remote host is the single most expensive action here, and // ending one reaches the same fleet. Both are metered exactly as their // REST equivalents already are. | "run_profile_remote" | "stop_remote_session" ) } pub async fn stop(&self) -> Result<(), String> { if !self.is_running() { return Err(crate::backend_error("MCP_SERVER_NOT_RUNNING")); } let mut inner = self.inner.lock().await; inner.app_handle = None; inner.token = None; inner.sessions.clear(); // Send shutdown signal if let Some(tx) = inner.shutdown_tx.take() { let _ = tx.send(()); } self.port.store(0, Ordering::SeqCst); self.is_running.store(false, Ordering::SeqCst); log::info!("[mcp] Server stopped"); Ok(()) } pub fn get_tools(&self) -> Vec { vec![ McpTool { name: "list_profiles".to_string(), description: "List all Wayfern browser profiles".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_profile".to_string(), description: "Get details of a specific browser profile".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to retrieve" } }, "required": ["profile_id"] }), }, McpTool { name: "run_profile".to_string(), description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to launch" }, "url": { "type": "string", "description": "Optional URL to open in the browser" }, "headless": { "type": "boolean", "description": "Run the browser in headless mode" } }, "required": ["profile_id"] }), }, McpTool { name: "kill_profile".to_string(), description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to stop" } }, "required": ["profile_id"] }), }, McpTool { name: "batch_run_profiles".to_string(), description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_ids": { "type": "array", "items": { "type": "string" }, "description": "UUIDs of the profiles to launch" }, "url": { "type": "string", "description": "Optional URL to open in every launched profile" }, "headless": { "type": "boolean", "description": "Run the browsers in headless mode" } }, "required": ["profile_ids"] }), }, McpTool { name: "batch_stop_profiles".to_string(), description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_ids": { "type": "array", "items": { "type": "string" }, "description": "UUIDs of the profiles to stop" } }, "required": ["profile_ids"] }), }, McpTool { name: "create_profile".to_string(), description: "Create a new browser profile".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Name for the new profile" }, "browser": { "type": "string", "enum": ["wayfern"], "description": "Browser engine to use" }, "proxy_id": { "type": "string", "description": "Optional proxy UUID to assign" }, "launch_hook": { "type": "string", "description": "Optional HTTP(S) URL to call before launch for transient proxy overrides" }, "group_id": { "type": "string", "description": "Optional group UUID to assign" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags for the profile" } }, "required": ["name", "browser"] }), }, McpTool { name: "detect_browser_profiles".to_string(), description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "folder": { "type": "string", "description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child." } } }), }, McpTool { name: "import_browser_profiles".to_string(), description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "source_path": { "type": "string", "description": "Path to the source profile directory" }, "new_profile_name": { "type": "string", "description": "Name for the imported profile" }, "proxy_id": { "type": "string", "description": "Optional proxy UUID to assign to this profile" }, "vpn_id": { "type": "string", "description": "Optional VPN UUID to assign to this profile" } }, "required": ["source_path", "new_profile_name"] }, "description": "Profiles to import" }, "group_id": { "type": "string", "description": "Optional group UUID assigned to every imported profile" }, "duplicate_strategy": { "type": "string", "enum": ["skip", "rename"], "description": "How to handle an already-taken profile name (default: rename with a numeric suffix)" } }, "required": ["items"] }), }, McpTool { name: "update_profile".to_string(), description: "Update an existing browser profile's settings".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to update" }, "name": { "type": "string", "description": "New name for the profile" }, "proxy_id": { "type": "string", "description": "Proxy UUID to assign (empty string to remove)" }, "launch_hook": { "type": "string", "description": "Launch hook URL to assign (empty string to remove)" }, "group_id": { "type": "string", "description": "Group UUID to assign (empty string to remove)" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for the profile (replaces existing tags)" }, "extension_group_id": { "type": "string", "description": "Extension group UUID to assign (empty string to remove)" }, "proxy_bypass_rules": { "type": "array", "items": { "type": "string" }, "description": "Proxy bypass rules (replaces existing rules)" }, "clear_on_close": { "type": "boolean", "description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles." } }, "required": ["profile_id"] }), }, McpTool { name: "delete_profile".to_string(), description: "Delete a browser profile and all its data".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to delete" } }, "required": ["profile_id"] }), }, McpTool { name: "list_tags".to_string(), description: "List all tags used across profiles".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "list_proxies".to_string(), description: "List all configured proxies".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_profile_status".to_string(), description: "Check whether a browser profile is running and can be driven. Returns is_running (true when the browser can be driven, wherever it is), location ('local', 'remote' or 'stopped'), is_running_locally, and remote_session_id when it is running on the remote fleet.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to check" } }, "required": ["profile_id"] }), }, // Group management tools McpTool { name: "list_groups".to_string(), description: "List all profile groups".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_group".to_string(), description: "Get details of a specific group".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "group_id": { "type": "string", "description": "The UUID of the group to retrieve" } }, "required": ["group_id"] }), }, McpTool { name: "create_group".to_string(), description: "Create a new profile group".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "The name for the new group" } }, "required": ["name"] }), }, McpTool { name: "update_group".to_string(), description: "Update an existing group's name".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "group_id": { "type": "string", "description": "The UUID of the group to update" }, "name": { "type": "string", "description": "The new name for the group" } }, "required": ["group_id", "name"] }), }, McpTool { name: "delete_group".to_string(), description: "Delete a profile group".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "group_id": { "type": "string", "description": "The UUID of the group to delete" } }, "required": ["group_id"] }), }, McpTool { name: "assign_profiles_to_group".to_string(), description: "Assign one or more profiles to a group".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_ids": { "type": "array", "items": { "type": "string" }, "description": "Array of profile UUIDs to assign" }, "group_id": { "type": "string", "description": "The UUID of the group to assign to (null to remove from group)" } }, "required": ["profile_ids"] }), }, // Full proxy management tools McpTool { name: "get_proxy".to_string(), description: "Get details of a specific proxy".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "proxy_id": { "type": "string", "description": "The UUID of the proxy to retrieve" } }, "required": ["proxy_id"] }), }, McpTool { name: "create_proxy".to_string(), description: "Create a new proxy configuration.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "The name for the new proxy" }, "proxy_type": { "type": "string", "enum": ["http", "https", "socks4", "socks5", "vless"], "description": "The proxy protocol" }, "host": { "type": "string", "description": "The proxy host address (for regular proxies)" }, "port": { "type": "integer", "description": "The proxy port number (for regular proxies)" }, "username": { "type": "string", "description": "Optional username for authentication (for regular proxies)" }, "password": { "type": "string", "description": "Optional password for authentication (for regular proxies)" }, "vless_uri": { "type": "string", "description": "VLESS + XTLS Vision + REALITY share URI" } }, "required": ["name", "proxy_type"] }), }, McpTool { name: "update_proxy".to_string(), description: "Update an existing proxy configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "proxy_id": { "type": "string", "description": "The UUID of the proxy to update" }, "name": { "type": "string", "description": "New name for the proxy" }, "proxy_type": { "type": "string", "enum": ["http", "https", "socks4", "socks5", "vless"], "description": "The proxy protocol" }, "host": { "type": "string", "description": "The proxy host address (for regular proxies)" }, "port": { "type": "integer", "description": "The proxy port number (for regular proxies)" }, "username": { "type": "string", "description": "Optional username for authentication (for regular proxies)" }, "password": { "type": "string", "description": "Optional password for authentication (for regular proxies)" }, "vless_uri": { "type": "string", "description": "VLESS + XTLS Vision + REALITY share URI" } }, "required": ["proxy_id"] }), }, McpTool { name: "delete_proxy".to_string(), description: "Delete a proxy configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "proxy_id": { "type": "string", "description": "The UUID of the proxy to delete" } }, "required": ["proxy_id"] }), }, McpTool { name: "export_proxies".to_string(), description: "Export all proxy configurations".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "format": { "type": "string", "enum": ["json", "txt"], "description": "Export format (json for structured data, txt for URL format)" } }, "required": ["format"] }), }, McpTool { name: "import_proxies".to_string(), description: "Import proxy configurations from JSON or TXT content".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "content": { "type": "string", "description": "The proxy configuration content to import" }, "format": { "type": "string", "enum": ["json", "txt"], "description": "Import format (json or txt)" }, "name_prefix": { "type": "string", "description": "Optional prefix for imported proxy names (default: 'Imported')" } }, "required": ["content", "format"] }), }, // VPN management tools McpTool { name: "import_vpn".to_string(), description: "Import a WireGuard (.conf) configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "content": { "type": "string", "description": "Raw WireGuard config file content" }, "filename": { "type": "string", "description": "Original filename (.conf)" }, "name": { "type": "string", "description": "Optional display name for the VPN config" } }, "required": ["content", "filename"] }), }, McpTool { name: "list_vpn_configs".to_string(), description: "List all stored VPN configurations".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "delete_vpn".to_string(), description: "Delete a VPN configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "vpn_id": { "type": "string", "description": "The UUID of the VPN config to delete" } }, "required": ["vpn_id"] }), }, McpTool { name: "connect_vpn".to_string(), description: "Connect to a VPN configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "vpn_id": { "type": "string", "description": "The UUID of the VPN config to connect" } }, "required": ["vpn_id"] }), }, McpTool { name: "disconnect_vpn".to_string(), description: "Disconnect from a VPN".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "vpn_id": { "type": "string", "description": "The UUID of the VPN to disconnect" } }, "required": ["vpn_id"] }), }, McpTool { name: "get_vpn_status".to_string(), description: "Get the connection status of a VPN".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "vpn_id": { "type": "string", "description": "The UUID of the VPN to check" } }, "required": ["vpn_id"] }), }, // Fingerprint management tools McpTool { name: "get_profile_fingerprint".to_string(), description: "Get the fingerprint configuration for a Wayfern profile" .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile" } }, "required": ["profile_id"] }), }, McpTool { name: "update_profile_fingerprint".to_string(), description: "Update the fingerprint configuration for a Wayfern profile. Requires an active Pro subscription." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to update" }, "fingerprint": { "type": "string", "description": "JSON string of the fingerprint configuration, or null to clear" }, "os": { "type": "string", "enum": ["windows", "macos", "linux"], "description": "Operating system for fingerprint generation" }, "randomize_fingerprint_on_launch": { "type": "boolean", "description": "Whether to generate a new fingerprint on every launch" } }, "required": ["profile_id"] }), }, McpTool { name: "update_profile_proxy_bypass_rules".to_string(), description: "Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to update" }, "rules": { "type": "array", "items": { "type": "string" }, "description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns." } }, "required": ["profile_id", "rules"] }), }, McpTool { name: "update_profile_dns_blocklist".to_string(), description: "Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to update" }, "level": { "type": "string", "enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"], "description": "DNS blocklist level. 'none' disables blocking." } }, "required": ["profile_id", "level"] }), }, McpTool { name: "get_dns_blocklist_status".to_string(), description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "list_extensions".to_string(), description: "List all managed browser extensions. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "list_extension_groups".to_string(), description: "List all extension groups. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "create_extension_group".to_string(), description: "Create a new extension group. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Name for the extension group" } }, "required": ["name"] }), }, McpTool { name: "delete_extension".to_string(), description: "Delete a managed extension. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "extension_id": { "type": "string", "description": "The extension ID to delete" } }, "required": ["extension_id"] }), }, McpTool { name: "delete_extension_group".to_string(), description: "Delete an extension group. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "group_id": { "type": "string", "description": "The extension group ID to delete" } }, "required": ["group_id"] }), }, McpTool { name: "assign_extension_group_to_profile".to_string(), description: "Assign an extension group to a profile, or remove the assignment. Requires Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The profile ID" }, "extension_group_id": { "type": "string", "description": "The extension group ID, or empty string to remove" } }, "required": ["profile_id"] }), }, // Cookie management tools McpTool { name: "import_profile_cookies".to_string(), description: "Import cookies into a Wayfern profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the target profile" }, "content": { "type": "string", "description": "Raw cookie file content (JSON array or Netscape cookies.txt)" } }, "required": ["profile_id", "content"] }), }, // Team lock tools McpTool { name: "get_team_locks".to_string(), description: "List all active team profile locks. Requires team plan.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_team_lock_status".to_string(), description: "Check if a profile is locked by a team member. Requires team plan.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to check" } }, "required": ["profile_id"] }), }, // Synchronizer tools McpTool { name: "start_sync_session".to_string(), description: "Start a synchronizer session. Launches a leader profile and follower profiles, then mirrors all actions from the leader to the followers in real time. Only Wayfern profiles are supported. Requires paid subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "leader_profile_id": { "type": "string", "description": "The UUID of the leader profile" }, "follower_profile_ids": { "type": "array", "items": { "type": "string" }, "description": "UUIDs of follower profiles" } }, "required": ["leader_profile_id", "follower_profile_ids"] }), }, McpTool { name: "stop_sync_session".to_string(), description: "Stop an active synchronizer session. Kills all follower profiles and the leader.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "session_id": { "type": "string", "description": "The sync session ID" } }, "required": ["session_id"] }), }, McpTool { name: "get_sync_sessions".to_string(), description: "List all active synchronizer sessions.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {} }), }, McpTool { name: "remove_sync_follower".to_string(), description: "Remove a follower from an active synchronizer session.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "session_id": { "type": "string", "description": "The sync session ID" }, "follower_profile_id": { "type": "string", "description": "The UUID of the follower to remove" } }, "required": ["session_id", "follower_profile_id"] }), }, // Browser interaction tools McpTool { name: "navigate".to_string(), description: "Navigate a running browser profile to a URL. Waits for the page to fully load before returning.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "url": { "type": "string", "description": "The URL to navigate to" } }, "required": ["profile_id", "url"] }), }, McpTool { name: "screenshot".to_string(), description: "Take a screenshot of the current page in a running browser profile. Returns base64-encoded image." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "format": { "type": "string", "enum": ["png", "jpeg", "webp"], "description": "Image format (default: png)" }, "quality": { "type": "integer", "description": "Image quality 0-100 for jpeg/webp (default: 80)" }, "full_page": { "type": "boolean", "description": "Capture the full scrollable page (default: false)" } }, "required": ["profile_id"] }), }, McpTool { name: "evaluate_javascript".to_string(), description: "Execute JavaScript in the context of the current page and return the result. Works with both static and dynamically-generated content. Set wait_for_load=true if the script triggers navigation (e.g., form.submit())." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "expression": { "type": "string", "description": "JavaScript expression to evaluate" }, "await_promise": { "type": "boolean", "description": "Whether to await the result if it's a Promise (default: false)" }, "wait_for_load": { "type": "boolean", "description": "Wait for page load after execution, use when the script triggers navigation like form.submit() (default: false)" } }, "required": ["profile_id", "expression"] }), }, McpTool { name: "click_element".to_string(), description: "Click on an element identified by a CSS selector. If the click triggers a page navigation, waits for the new page to load before returning.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "selector": { "type": "string", "description": "CSS selector for the element to click" } }, "required": ["profile_id", "selector"] }), }, McpTool { name: "type_text".to_string(), description: "Focus an element by CSS selector and type text into it. By default uses realistic human-like typing with variable speed, natural errors, and self-corrections. Only set instant=true when you are certain the target does not have bot detection (e.g. browser address bars, developer tools, internal apps) — using instant on public websites risks the profile being flagged as a bot.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "selector": { "type": "string", "description": "CSS selector for the input element" }, "text": { "type": "string", "description": "Text to type into the element" }, "clear_first": { "type": "boolean", "description": "Clear the input before typing (default: true)" }, "instant": { "type": "boolean", "description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection — using this on public websites risks the profile being flagged." }, "wpm": { "type": "number", "description": "Target words per minute for human typing (default: 80)" } }, "required": ["profile_id", "selector", "text"] }), }, McpTool { name: "get_page_content".to_string(), description: "Get the content of the current page. Works with both static HTML and JavaScript-rendered content." .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "format": { "type": "string", "enum": ["html", "text"], "description": "Content format: 'html' for full HTML, 'text' for visible text only (default: text)" }, "selector": { "type": "string", "description": "Optional CSS selector to get content of a specific element instead of the whole page" } }, "required": ["profile_id"] }), }, McpTool { name: "get_page_info".to_string(), description: "Get metadata about the current page including URL, title, and readiness state" .to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" } }, "required": ["profile_id"] }), }, McpTool { name: "get_interactive_elements".to_string(), description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "max_chars": { "type": "integer", "description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff." } }, "required": ["profile_id"] }), }, McpTool { name: "click_by_index".to_string(), description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "index": { "type": "integer", "description": "Zero-based index from the last get_interactive_elements response" } }, "required": ["profile_id", "index"] }), }, McpTool { name: "type_by_index".to_string(), description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the running profile" }, "index": { "type": "integer", "description": "Zero-based index from the last get_interactive_elements response" }, "text": { "type": "string", "description": "Text to type into the element" }, "clear_first": { "type": "boolean", "description": "Clear the input before typing (default: true)" }, "instant": { "type": "boolean", "description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection." }, "wpm": { "type": "number", "description": "Target words per minute for human typing (default: 80)" } }, "required": ["profile_id", "index", "text"] }), }, // Remote fleet. An agent that could drive a remote profile but not start // one had to be handed a session by something else — the REST API or the // GUI — which is no use to an MCP client running on its own. McpTool { name: "run_profile_remote".to_string(), description: "Start this profile on a remote host of its own operating system. The profile must have Regular cloud sync enabled. Returns a session id; poll get_remote_session until state is 'live', then drive it with navigate, screenshot, click_element and the rest exactly as you would a local profile.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to run remotely" }, "url": { "type": "string", "description": "Optional URL to open once the browser is up" } }, "required": ["profile_id"] }), }, McpTool { name: "stop_remote_session".to_string(), description: "Stop a remote session and settle what it cost. A session left running bills until the fleet's two-hour cap, so stop one as soon as you are done with it".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "session_id": { "type": "string", "description": "Session id returned by run_profile_remote" } }, "required": ["session_id"] }), }, // Observability. `run_profile_remote` hands back a session id and the // word "provisioning"; without these an agent can only learn that a // session became usable by trying to drive it and failing. McpTool { name: "list_remote_sessions".to_string(), description: "List the remote browser sessions this account currently owns, with their live status".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_remote_session".to_string(), description: "Read one remote session's real state: provisioning, ready, live or closed, plus whether it can be driven yet".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "session_id": { "type": "string", "description": "Session id returned when the remote session was started" } }, "required": ["session_id"] }), }, McpTool { name: "get_remote_hours_quota".to_string(), description: "Read the pooled remote-hour budget. Bot runs and interactive remote sessions spend the same pool".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, // Cookie bot. Every one of these is a proxy onto Donut cloud, which owns // the schedule and the browsing behaviour; the tools carry only the // user's own choices. McpTool { name: "list_cookie_bot_schedules".to_string(), description: "List profiles enrolled in the nightly cookie bot".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "scope": { "type": "string", "enum": ["mine", "team"], "description": "Whose enrolments to list (default: mine)" } }, "required": [] }), }, McpTool { name: "get_cookie_bot_schedule".to_string(), description: "Get one profile's cookie-bot enrolment, or null when it is not enrolled".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile" } }, "required": ["profile_id"] }), }, McpTool { name: "set_cookie_bot_schedule".to_string(), description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows or macOS operating system, and a proxy or VPN".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to enrol" }, "profile_name": { "type": "string", "description": "Label shown in run history (default: the profile's own name)" }, "platform": { "type": "string", "enum": ["windows", "macos"], "description": "Must match the profile's own operating system; taken from the profile when omitted" }, "enabled": { "type": "boolean", "description": "Whether the nightly run is armed" }, "run_at_minute": { "type": "integer", "description": "Minutes past local midnight, 0-1439" }, "days_mask": { "type": "integer", "description": "Bitmask of local weekdays, bit 0 = Monday, 1-127" }, "timezone": { "type": "string", "description": "IANA zone the run time is expressed in, e.g. Europe/Berlin" }, "preset": { "type": "string", "description": "Preset id from list_cookie_bot_presets" }, "max_minutes": { "type": "integer", "description": "Upper bound on one run, in minutes" }, "sites": { "type": "array", "items": { "type": "string" }, "description": "Absolute http(s) URLs to browse. The bot visits only these" }, "jitter_seconds": { "type": "integer", "description": "Random spread around the run time, in seconds" }, "acknowledge_conflict": { "type": "boolean", "description": "Write anyway when a teammate already enrols this profile" } }, "required": ["profile_id", "enabled", "run_at_minute", "days_mask", "timezone", "preset", "max_minutes"] }), }, McpTool { name: "delete_cookie_bot_schedule".to_string(), description: "Turn the cookie bot off for a profile. Safe to repeat; a run already in flight is not cancelled".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile to unenrol" } }, "required": ["profile_id"] }), }, McpTool { name: "check_cookie_bot_conflicts".to_string(), description: "Ask, without writing anything, which teammates already enrol this profile and whether a proposed time would overlap theirs".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the profile" }, "run_at_minute": { "type": "integer", "description": "Proposed minutes past local midnight, 0-1439" }, "timezone": { "type": "string", "description": "Proposed IANA zone" }, "days_mask": { "type": "integer", "description": "Proposed weekday bitmask, bit 0 = Monday" } }, "required": ["profile_id"] }), }, McpTool { name: "list_cookie_bot_runs".to_string(), description: "List cookie-bot runs, newest first, with how many sites each visited and what it cost".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "Restrict to one profile" }, "scope": { "type": "string", "enum": ["mine", "team"], "description": "Whose runs to list (default: mine)" }, "limit": { "type": "integer", "description": "Page size, 1-100 (default: 30)" }, "before": { "type": "string", "description": "Keyset cursor from a previous page's next_before" } }, "required": [] }), }, McpTool { name: "run_cookie_bot_now".to_string(), description: "Start a cookie-bot run immediately instead of waiting for the schedule. The profile must already be enrolled: the preset and site list live in its schedule. Requires an active Pro subscription and spends the pooled remote-hour budget".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "profile_id": { "type": "string", "description": "The UUID of the enrolled profile to warm" }, "max_minutes": { "type": "integer", "description": "Cap this run only, overriding the schedule's own" } }, "required": ["profile_id"] }), }, McpTool { name: "cancel_cookie_bot_run".to_string(), description: "Stop a cookie-bot run that is still going. Idempotent: cancelling a finished run returns it unchanged".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "run_id": { "type": "string", "description": "Run id from list_cookie_bot_runs" } }, "required": ["run_id"] }), }, McpTool { name: "list_cookie_bot_presets".to_string(), description: "List the cookie-bot intensities that can be chosen, with roughly how long each takes".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), }, McpTool { name: "get_cookie_bot_usage".to_string(), description: "Per-member and per-profile cookie-bot spend for a calendar month. Reporting only".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "period": { "type": "string", "description": "Calendar month as YYYY-MM (default: the current UTC month)" } }, "required": [] }), }, ] } async fn handle_initialize( &self, request: McpRequest, ) -> Result<(String, (serde_json::Value, serde_json::Value)), (serde_json::Value, McpError)> { let id = request.id.clone().unwrap_or(serde_json::Value::Null); if !self.is_running() { return Err(( id, McpError { code: -32001, message: "MCP server is not running".to_string(), }, )); } // Create session let session_id = Uuid::new_v4().to_string(); { let mut inner = self.inner.lock().await; inner .sessions .insert(session_id.clone(), McpSession { initialized: false }); } let result = serde_json::json!({ "protocolVersion": PROTOCOL_VERSION, "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION, }, "instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools." }); log::info!("[mcp] New session initialized: {}", session_id); Ok((session_id, (id, result))) } pub async fn handle_request(&self, request: McpRequest) -> McpResponse { let id = request.id.clone().unwrap_or(serde_json::Value::Null); if !self.is_running() { return McpResponse { jsonrpc: "2.0".to_string(), id: Some(id), result: None, error: Some(McpError { code: -32001, message: "MCP server is not running".to_string(), }), }; } let result = match request.method.as_str() { "ping" => Ok(serde_json::json!({})), "tools/list" => self.handle_tools_list().await, "tools/call" => self.handle_tool_call(request.params).await, _ => Err(McpError { code: -32601, message: format!("Method not found: {}", request.method), }), }; match result { Ok(value) => McpResponse { jsonrpc: "2.0".to_string(), id: Some(id), result: Some(value), error: None, }, Err(error) => McpResponse { jsonrpc: "2.0".to_string(), id: Some(id), result: None, error: Some(error), }, } } async fn handle_tools_list(&self) -> Result { Ok(serde_json::json!({ "tools": self.get_tools() })) } async fn handle_tool_call( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| McpError { code: -32602, message: "Missing parameters".to_string(), })?; let tool_name = params .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing tool name".to_string(), })?; let arguments = params .get("arguments") .cloned() .unwrap_or(serde_json::json!({})); // Surface the call in logs so customer reports show which tools the MCP // client is actually invoking (and therefore which gate any subsequent // error came from). Log only the tool name and the profile_id arg — // arbitrary URLs / JS / selectors can be sensitive. let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .unwrap_or(""); log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}"); let started = std::time::Instant::now(); let result = self.dispatch_tool_call(tool_name, &arguments).await; let elapsed_ms = started.elapsed().as_millis(); match &result { Ok(_) => { log::info!( "[mcp] tools/call name={tool_name} profile_id={profile_id} -> ok ({elapsed_ms} ms)" ); } Err(e) => { log::warn!( "[mcp] tools/call name={tool_name} profile_id={profile_id} -> error code={} msg={:?} ({elapsed_ms} ms)", e.code, e.message ); } } result } async fn dispatch_tool_call( &self, tool_name: &str, arguments: &serde_json::Value, ) -> Result { match tool_name { "list_profiles" => self.handle_list_profiles().await, "get_profile" => self.handle_get_profile(arguments).await, "run_profile" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_run_profile(arguments).await } "kill_profile" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_kill_profile(arguments).await } "batch_run_profiles" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_batch_run_profiles(arguments).await } "batch_stop_profiles" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_batch_stop_profiles(arguments).await } "create_profile" => self.handle_create_profile(arguments).await, // Profile import (free, like create_profile — importing is not automation) "detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await, "import_browser_profiles" => self.handle_import_browser_profiles(arguments).await, "update_profile" => self.handle_update_profile(arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await, "list_tags" => self.handle_list_tags().await, "list_proxies" => self.handle_list_proxies().await, "get_profile_status" => self.handle_get_profile_status(arguments).await, // Group management "list_groups" => self.handle_list_groups().await, "get_group" => self.handle_get_group(arguments).await, "create_group" => self.handle_create_group(arguments).await, "update_group" => self.handle_update_group(arguments).await, "delete_group" => self.handle_delete_group(arguments).await, "assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await, // Full proxy management "get_proxy" => self.handle_get_proxy(arguments).await, "create_proxy" => self.handle_create_proxy(arguments).await, "update_proxy" => self.handle_update_proxy(arguments).await, "delete_proxy" => self.handle_delete_proxy(arguments).await, // Proxy import/export "export_proxies" => self.handle_export_proxies(arguments).await, "import_proxies" => self.handle_import_proxies(arguments).await, // VPN management "import_vpn" => self.handle_import_vpn(arguments).await, "list_vpn_configs" => self.handle_list_vpn_configs().await, "delete_vpn" => self.handle_delete_vpn(arguments).await, "connect_vpn" => self.handle_connect_vpn(arguments).await, "disconnect_vpn" => self.handle_disconnect_vpn(arguments).await, "get_vpn_status" => self.handle_get_vpn_status(arguments).await, // Fingerprint management — viewing is free everywhere (matches the REST // API and the get_profile tool, which already expose the config); only // editing requires a paid plan. "get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await, "update_profile_fingerprint" => { Self::require_capability( "Fingerprint editing", CLOUD_AUTH.can_use_cross_os_fingerprints().await, ) .await?; self.handle_update_profile_fingerprint(arguments).await } "update_profile_proxy_bypass_rules" => { self .handle_update_profile_proxy_bypass_rules(arguments) .await } // DNS blocklist management "update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(arguments).await, "get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await, // Extension management "list_extensions" => self.handle_list_extensions().await, "list_extension_groups" => self.handle_list_extension_groups().await, "create_extension_group" => self.handle_create_extension_group(arguments).await, "delete_extension" => self.handle_delete_extension_mcp(arguments).await, "delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await, "assign_extension_group_to_profile" => { self .handle_assign_extension_group_to_profile(arguments) .await } // Cookie management "import_profile_cookies" => self.handle_import_profile_cookies(arguments).await, // Team lock tools "get_team_locks" => self.handle_get_team_locks().await, "get_team_lock_status" => self.handle_get_team_lock_status(arguments).await, // Synchronizer tools "start_sync_session" => { Self::require_capability( "Synchronizer", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_start_sync_session(arguments).await } "stop_sync_session" => self.handle_stop_sync_session(arguments).await, "get_sync_sessions" => self.handle_get_sync_sessions().await, "remove_sync_follower" => self.handle_remove_sync_follower(arguments).await, // Browser interaction tools (require paid subscription) "navigate" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_navigate(arguments).await } "screenshot" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_screenshot(arguments).await } "evaluate_javascript" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_evaluate_javascript(arguments).await } "click_element" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_click_element(arguments).await } "type_text" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_type_text(arguments).await } "get_page_content" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_get_page_content(arguments).await } "get_page_info" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_get_page_info(arguments).await } "get_interactive_elements" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_get_interactive_elements(arguments).await } "click_by_index" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_click_by_index(arguments).await } "type_by_index" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_type_by_index(arguments).await } // Leasing a host is the most expensive thing this server can do, so it // is gated exactly like the local launch it replaces. "run_profile_remote" => { Self::require_capability( "Browser automation", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; self.handle_run_profile_remote(arguments).await } // No capability gate on the stop. A lapsed plan must never be the reason // an agent cannot end something that is spending hours. "stop_remote_session" => Self::handle_stop_remote_session(arguments).await, // Remote fleet observability. Reads only, and free: being unable to see // that a session you are already paying for has become usable is not a // feature worth withholding. "list_remote_sessions" => Self::handle_list_remote_sessions().await, "get_remote_session" => Self::handle_get_remote_session(arguments).await, "get_remote_hours_quota" => Self::handle_get_remote_hours_quota().await, // Cookie bot. Reading and configuring are free; only starting a run, // which leases a host and spends the pooled hours, needs the plan. "list_cookie_bot_schedules" => Self::handle_list_cookie_bot_schedules(arguments).await, "get_cookie_bot_schedule" => Self::handle_get_cookie_bot_schedule(arguments).await, "set_cookie_bot_schedule" => Self::handle_set_cookie_bot_schedule(arguments).await, "delete_cookie_bot_schedule" => Self::handle_delete_cookie_bot_schedule(arguments).await, "check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await, "list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await, "run_cookie_bot_now" => { // The Cookie Bot, NOT browser automation. Solo pays for the bot and has // no automation; gating this on automation refused a Solo customer the // feature their plan is sold on while their scheduled runs kept firing. Self::require_capability("Cookie Bot", CLOUD_AUTH.can_use_cookie_bot().await).await?; Self::handle_run_cookie_bot_now(arguments).await } // No capability gate on the cancel. A lapsed plan must never be the // reason an agent cannot stop something that is spending hours. "cancel_cookie_bot_run" => Self::handle_cancel_cookie_bot_run(arguments).await, "list_cookie_bot_presets" => Self::handle_list_cookie_bot_presets().await, "get_cookie_bot_usage" => Self::handle_get_cookie_bot_usage(arguments).await, _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), }), } } async fn handle_list_profiles(&self) -> Result { let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; // Filter to only Wayfern profiles let filtered: Vec<&BrowserProfile> = profiles.iter().filter(|p| p.browser == "wayfern").collect(); Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&filtered).unwrap_or_default() }] })) } async fn handle_get_profile( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; // Check if it's a Wayfern profile if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }); } Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&profile).unwrap_or_default() }] })) } async fn handle_run_profile( &self, arguments: &serde_json::Value, ) -> Result { // Launching profiles programmatically requires the automation capability. Self::require_capability( "Launching a profile", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let url = arguments.get("url").and_then(|v| v.as_str()); let headless = arguments .get("headless") .and_then(|v| v.as_bool()) .unwrap_or(false); // Get the profile let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; // Check if it's a Wayfern profile if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }); } // Team lock check crate::team_lock::acquire_team_lock_if_needed(profile) .await .map_err(|e| McpError { code: -32000, message: e, })?; // Get app handle to launch let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; // Launch a fresh instance, honoring the requested headless mode. The CDP // port is self-allocated and discovered later via get_cdp_port_for_profile. crate::browser_runner::launch_browser_profile_impl( app_handle.clone(), profile.clone(), url.map(|s| s.to_string()), crate::browser_runner::LaunchOptions::automation(None, headless), ) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to launch browser: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Browser profile '{}' launched successfully", profile.name) }] })) } async fn handle_kill_profile( &self, arguments: &serde_json::Value, ) -> Result { // Stopping profiles programmatically requires the automation capability. Self::require_capability( "Killing a profile", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; // Get the profile let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; // Check if it's a Wayfern profile if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }); } // Get app handle to kill let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; // Kill the browser crate::browser_runner::BrowserRunner::instance() .kill_browser_process(app_handle.clone(), profile) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to kill browser: {e}"), })?; crate::team_lock::release_team_lock_if_needed(profile).await; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Browser profile '{}' stopped successfully", profile.name) }] })) } async fn handle_batch_run_profiles( &self, arguments: &serde_json::Value, ) -> Result { Self::require_capability( "Batch launching profiles", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; let profile_ids: Vec = arguments .get("profile_ids") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids array".to_string(), })?; let url = arguments.get("url").and_then(|v| v.as_str()); let headless = arguments .get("headless") .and_then(|v| v.as_bool()) .unwrap_or(false); let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; // Clone the app handle and release the lock before the launch loop so we // never hold the inner mutex across the per-profile awaits. let app_handle = { let inner = self.inner.lock().await; inner .app_handle .as_ref() .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? .clone() }; let mut launched = 0usize; let mut lines: Vec = Vec::with_capacity(profile_ids.len()); for profile_id in &profile_ids { let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { lines.push(format!("{profile_id}: not found")); continue; }; if profile.browser != "wayfern" { lines.push(format!( "{profile_id}: unsupported browser (MCP supports Wayfern)" )); continue; } if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await { lines.push(format!("{profile_id}: {e}")); continue; } match crate::browser_runner::launch_browser_profile_impl( app_handle.clone(), profile.clone(), url.map(|s| s.to_string()), crate::browser_runner::LaunchOptions::automation(None, headless), ) .await { Ok(_) => { launched += 1; lines.push(format!("{}: launched", profile.name)); } Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)), } } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n")) }] })) } async fn handle_batch_stop_profiles( &self, arguments: &serde_json::Value, ) -> Result { Self::require_capability( "Batch stopping profiles", CLOUD_AUTH.can_use_browser_automation().await, ) .await?; let profile_ids: Vec = arguments .get("profile_ids") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids array".to_string(), })?; let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let app_handle = { let inner = self.inner.lock().await; inner .app_handle .as_ref() .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? .clone() }; let mut stopped = 0usize; let mut lines: Vec = Vec::with_capacity(profile_ids.len()); for profile_id in &profile_ids { let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { lines.push(format!("{profile_id}: not found")); continue; }; match crate::browser_runner::BrowserRunner::instance() .kill_browser_process(app_handle.clone(), profile) .await { Ok(_) => { crate::team_lock::release_team_lock_if_needed(profile).await; stopped += 1; lines.push(format!("{}: stopped", profile.name)); } Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)), } } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n")) }] })) } async fn handle_create_profile( &self, arguments: &serde_json::Value, ) -> Result { let name = arguments .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), })?; let browser = arguments .get("browser") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing browser".to_string(), })?; if browser != "wayfern" { return Err(McpError { code: -32602, message: "browser must be 'wayfern'".to_string(), }); } let proxy_id = arguments .get("proxy_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let launch_hook = arguments .get("launch_hook") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let tags: Option> = arguments.get("tags").and_then(|v| { v.as_array().map(|arr| { arr .iter() .filter_map(|item| item.as_str().map(|s| s.to_string())) .collect() }) }); // Pick the latest downloaded version for this browser let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); let versions = registry.get_downloaded_versions(browser); let version = versions.first().ok_or_else(|| McpError { code: -32000, message: format!("No downloaded version found for {browser}. Download it first."), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let mut profile = ProfileManager::instance() .create_profile_with_group( app_handle, name, browser, version, "stable", proxy_id, None, None, group_id, false, None, launch_hook, ) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to create profile: {e}"), })?; if let Some(tags) = tags { let _ = ProfileManager::instance().update_profile_tags(app_handle, &profile.name, tags.clone()); profile.tags = tags; if let Ok(profiles) = ProfileManager::instance().list_profiles() { let _ = crate::tag_manager::TAG_MANAGER .lock() .map(|manager| manager.rebuild_from_profiles(&profiles)); } } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Profile '{}' created (id: {})", profile.name, profile.id) }] })) } async fn handle_update_profile( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let pm = ProfileManager::instance(); if let Some(new_name) = arguments.get("name").and_then(|v| v.as_str()) { pm.rename_profile(app_handle, profile_id, new_name) .map_err(|e| McpError { code: -32000, message: format!("Failed to rename profile: {e}"), })?; } if let Some(proxy_id) = arguments.get("proxy_id").and_then(|v| v.as_str()) { let pid = if proxy_id.is_empty() { None } else { Some(proxy_id.to_string()) }; pm.update_profile_proxy(app_handle.clone(), profile_id, pid) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy: {e}"), })?; } if let Some(launch_hook) = arguments.get("launch_hook").and_then(|v| v.as_str()) { let normalized = if launch_hook.is_empty() { None } else { Some(launch_hook.to_string()) }; pm.update_profile_launch_hook(app_handle, profile_id, normalized) .map_err(|e| McpError { code: -32000, message: format!("Failed to update launch hook: {e}"), })?; } if let Some(group_id) = arguments.get("group_id").and_then(|v| v.as_str()) { let gid = if group_id.is_empty() { None } else { Some(group_id.to_string()) }; pm.assign_profiles_to_group(app_handle, vec![profile_id.to_string()], gid) .map_err(|e| McpError { code: -32000, message: format!("Failed to update group: {e}"), })?; } if let Some(tags) = arguments.get("tags").and_then(|v| v.as_array()) { let tag_list: Vec = tags .iter() .filter_map(|item| item.as_str().map(|s| s.to_string())) .collect(); pm.update_profile_tags(app_handle, profile_id, tag_list) .map_err(|e| McpError { code: -32000, message: format!("Failed to update tags: {e}"), })?; if let Ok(profiles) = pm.list_profiles() { let _ = crate::tag_manager::TAG_MANAGER .lock() .map(|manager| manager.rebuild_from_profiles(&profiles)); } } if let Some(ext_group_id) = arguments.get("extension_group_id").and_then(|v| v.as_str()) { let eid = if ext_group_id.is_empty() { None } else { Some(ext_group_id.to_string()) }; pm.update_profile_extension_group(profile_id, eid) .map_err(|e| McpError { code: -32000, message: format!("Failed to update extension group: {e}"), })?; } if let Some(rules) = arguments .get("proxy_bypass_rules") .and_then(|v| v.as_array()) { let rule_list: Vec = rules .iter() .filter_map(|item| item.as_str().map(|s| s.to_string())) .collect(); pm.update_profile_proxy_bypass_rules(app_handle, profile_id, rule_list) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy bypass rules: {e}"), })?; } if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) { pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close) .map_err(|e| McpError { code: -32000, message: format!("Failed to update clear-on-close: {e}"), })?; } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Profile '{profile_id}' updated successfully") }] })) } async fn handle_delete_profile( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; ProfileManager::instance() .delete_profile(app_handle, profile_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete profile: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Profile '{profile_id}' deleted successfully") }] })) } async fn handle_list_tags(&self) -> Result { let tags = crate::tag_manager::TAG_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to access tag manager: {e}"), })? .get_all_tags() .map_err(|e| McpError { code: -32000, message: format!("Failed to get tags: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&tags).unwrap_or_default() }] })) } async fn handle_list_proxies(&self) -> Result { let proxies = PROXY_MANAGER.get_stored_proxies(); Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&proxies).unwrap_or_default() }] })) } async fn handle_get_profile_status( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; // Get the profile let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; // Check if it's a Wayfern profile if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }); } // "Running" has to mean "drivable", not "has a process on this machine". // A profile open on the leased fleet has no local process, and answering // `is_running: false` for it tells an agent not to bother calling the very // tools that would have worked. let is_running_locally = profile.process_id.is_some(); let remote_session = if is_running_locally { None } else { crate::remote_session::live_session_for_profile(profile_id).await }; let location = match (is_running_locally, &remote_session) { (true, _) => "local", (false, Some(_)) => "remote", (false, None) => "stopped", }; // Whether a LOCAL launch would be refused, and why. An agent that reads // `location: "stopped"` and calls `run_profile` on a profile whose finished // remote session has not been pulled back would get a bare 409 with nothing // to act on; worse, before the gate existed it would have got a browser and // silently destroyed the session's work. let handoff = crate::remote_handoff::state_for(profile_id); let can_launch_locally = handoff.is_none() && !is_running_locally; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::json!({ "profile_id": profile_id, "is_running": location != "stopped", "is_running_locally": is_running_locally, "location": location, "remote_session_id": remote_session.map(|session| session.session_id), "can_launch_locally": can_launch_locally, "local_launch_blocked_by": match handoff { Some(crate::remote_handoff::HandoffState::Running) => Some("remote_session_running"), Some(crate::remote_handoff::HandoffState::PendingSync) => { Some("remote_session_changes_downloading") } None => None, }, }).to_string() }] })) } // Group management handlers async fn handle_list_groups(&self) -> Result { let groups = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), })? .get_all_groups() .map_err(|e| McpError { code: -32000, message: format!("Failed to list groups: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&groups).unwrap_or_default() }] })) } async fn handle_get_group( &self, arguments: &serde_json::Value, ) -> Result { let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), })?; let groups = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), })? .get_all_groups() .map_err(|e| McpError { code: -32000, message: format!("Failed to list groups: {e}"), })?; let group = groups .iter() .find(|g| g.id == group_id) .ok_or_else(|| McpError { code: -32000, message: format!("Group not found: {group_id}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&group).unwrap_or_default() }] })) } async fn handle_create_group( &self, arguments: &serde_json::Value, ) -> Result { let name = arguments .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let group = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), })? .create_group(app_handle, name.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to create group: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Group '{}' created successfully with ID: {}", group.name, group.id) }] })) } async fn handle_update_group( &self, arguments: &serde_json::Value, ) -> Result { let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), })?; let name = arguments .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let group = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), })? .update_group(app_handle, group_id.to_string(), name.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to update group: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Group '{}' updated successfully", group.name) }] })) } async fn handle_delete_group( &self, arguments: &serde_json::Value, ) -> Result { let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), })? .delete_group(app_handle, group_id.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete group: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Group '{}' deleted successfully", group_id) }] })) } async fn handle_assign_profiles_to_group( &self, arguments: &serde_json::Value, ) -> Result { let profile_ids: Vec = arguments .get("profile_ids") .and_then(|v| v.as_array()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids".to_string(), })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; ProfileManager::instance() .assign_profiles_to_group(app_handle, profile_ids.clone(), group_id.clone()) .map_err(|e| McpError { code: -32000, message: format!("Failed to assign profiles to group: {e}"), })?; let group_name = group_id.as_deref().unwrap_or("default"); Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("{} profile(s) assigned to group '{}'", profile_ids.len(), group_name) }] })) } // Full proxy management handlers async fn handle_get_proxy( &self, arguments: &serde_json::Value, ) -> Result { let proxy_id = arguments .get("proxy_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), })?; let proxies = PROXY_MANAGER.get_stored_proxies(); let proxy = proxies .iter() .find(|p| p.id == proxy_id) .ok_or_else(|| McpError { code: -32000, message: format!("Proxy not found: {proxy_id}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&proxy).unwrap_or_default() }] })) } async fn handle_create_proxy( &self, arguments: &serde_json::Value, ) -> Result { let name = arguments .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let proxy_type = arguments .get("proxy_type") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_type".to_string(), })?; // The tool schema declares an enum, but JSON-Schema enums are advisory only; // enforce it here so a bad value can't produce a non-functional proxy. if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5" | "vless") { return Err(McpError { code: -32602, message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(), }); } let vless_uri = arguments .get("vless_uri") .and_then(|value| value.as_str()) .map(str::to_string); let (host, port) = if proxy_type == "vless" { if vless_uri.is_none() { return Err(McpError { code: -32602, message: "Missing vless_uri".to_string(), }); } (String::new(), 1) } else { let host = arguments .get("host") .and_then(|value| value.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing host".to_string(), })? .to_string(); let port = arguments .get("port") .and_then(|value| value.as_u64()) .and_then(|port| u16::try_from(port).ok()) .filter(|port| *port != 0) .ok_or_else(|| McpError { code: -32602, message: "Missing or invalid port".to_string(), })?; (host, port) }; let username = arguments .get("username") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let password = arguments .get("password") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let proxy_settings = ProxySettings { proxy_type: proxy_type.to_string(), host, port, username, password, vless_uri, }; let proxy = PROXY_MANAGER .create_stored_proxy(app_handle, name.to_string(), proxy_settings) .map_err(|e| McpError { code: -32000, message: format!("Failed to create proxy: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Proxy '{}' created successfully with ID: {}", proxy.name, proxy.id) }] })) } async fn handle_update_proxy( &self, arguments: &serde_json::Value, ) -> Result { let proxy_id = arguments .get("proxy_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), })?; let name = arguments .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); // Build proxy_settings if any settings fields are provided let has_settings = arguments.get("proxy_type").is_some() || arguments.get("host").is_some() || arguments.get("port").is_some() || arguments.get("username").is_some() || arguments.get("password").is_some() || arguments.get("vless_uri").is_some(); let proxy_settings = if has_settings { // Get existing proxy to use as defaults let proxies = PROXY_MANAGER.get_stored_proxies(); let existing = proxies .iter() .find(|p| p.id == proxy_id) .ok_or_else(|| McpError { code: -32000, message: format!("Proxy not found: {proxy_id}"), })?; let proxy_type = arguments .get("proxy_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| existing.proxy_settings.proxy_type.clone()); if !matches!( proxy_type.as_str(), "http" | "https" | "socks4" | "socks5" | "vless" ) { return Err(McpError { code: -32602, message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(), }); } let host = arguments .get("host") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| existing.proxy_settings.host.clone()); let port = match arguments.get("port") { Some(value) => value .as_u64() .and_then(|port| u16::try_from(port).ok()) .filter(|port| *port != 0) .ok_or_else(|| McpError { code: -32602, message: "Invalid port".to_string(), })?, None => existing.proxy_settings.port, }; let username = arguments .get("username") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| existing.proxy_settings.username.clone()); let password = arguments .get("password") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| existing.proxy_settings.password.clone()); let vless_uri = arguments .get("vless_uri") .and_then(|value| value.as_str()) .map(str::to_string) .or_else(|| existing.proxy_settings.vless_uri.clone()); Some(ProxySettings { proxy_type, host, port, username, password, vless_uri, }) } else { None }; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let proxy = PROXY_MANAGER .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Proxy '{}' updated successfully", proxy.name) }] })) } async fn handle_delete_proxy( &self, arguments: &serde_json::Value, ) -> Result { let proxy_id = arguments .get("proxy_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; PROXY_MANAGER .delete_stored_proxy(app_handle, proxy_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete proxy: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Proxy '{}' deleted successfully", proxy_id) }] })) } async fn handle_export_proxies( &self, arguments: &serde_json::Value, ) -> Result { let format = arguments .get("format") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing format".to_string(), })?; let content = match format { "json" => PROXY_MANAGER.export_proxies_json().map_err(|e| McpError { code: -32000, message: format!("Failed to export proxies: {e}"), })?, "txt" => PROXY_MANAGER.export_proxies_txt(), _ => { return Err(McpError { code: -32602, message: format!("Invalid format '{}', must be 'json' or 'txt'", format), }) } }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": content }] })) } async fn handle_import_proxies( &self, arguments: &serde_json::Value, ) -> Result { let content = arguments .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), })?; let format = arguments .get("format") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing format".to_string(), })?; let name_prefix = arguments .get("name_prefix") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let result = match format { "json" => PROXY_MANAGER .import_proxies_json(app_handle, content) .map_err(|e| McpError { code: -32000, message: format!("Failed to import proxies: {e}"), })?, "txt" => { use crate::proxy_manager::{ProxyManager, ProxyParseResult}; let parse_results = ProxyManager::parse_txt_proxies(content); let parsed: Vec<_> = parse_results .into_iter() .filter_map(|r| { if let ProxyParseResult::Parsed(p) = r { Some(p) } else { None } }) .collect(); if parsed.is_empty() { return Err(McpError { code: -32000, message: "No valid proxies found in content".to_string(), }); } PROXY_MANAGER .import_proxies_from_parsed(app_handle, parsed, name_prefix) .map_err(|e| McpError { code: -32000, message: format!("Failed to import proxies: {e}"), })? } _ => { return Err(McpError { code: -32602, message: format!("Invalid format '{}', must be 'json' or 'txt'", format), }) } }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "Import complete: {} imported, {} skipped, {} errors", result.imported_count, result.skipped_count, result.errors.len() ) }] })) } // Profile import handlers async fn handle_detect_browser_profiles( &self, arguments: &serde_json::Value, ) -> Result { let importer = crate::profile_importer::ProfileImporter::instance(); let profiles = match arguments.get("folder").and_then(|v| v.as_str()) { Some(folder) => importer.scan_folder(std::path::Path::new(folder)), None => importer.detect_existing_profiles(), } .map_err(|e| McpError { code: -32000, message: format!("Failed to detect profiles: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string()) }] })) } async fn handle_import_browser_profiles( &self, arguments: &serde_json::Value, ) -> Result { let items: Vec = arguments .get("items") .cloned() .ok_or_else(|| McpError { code: -32602, message: "Missing items".to_string(), }) .and_then(|v| { serde_json::from_value(v).map_err(|e| McpError { code: -32602, message: format!("Invalid items: {e}"), }) })?; let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let duplicate_strategy = arguments .get("duplicate_strategy") .cloned() .map(serde_json::from_value::) .transpose() .map_err(|e| McpError { code: -32602, message: format!("Invalid duplicate_strategy: {e}"), })? .unwrap_or_default(); // Clone the handle instead of holding the inner lock across a potentially // multi-GB copy. let app_handle = { let inner = self.inner.lock().await; inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? }; let result = crate::profile_importer::ProfileImporter::instance() .import_profiles(&app_handle, items, group_id, duplicate_strategy, None) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to import profiles: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "Import complete: {} imported, {} skipped, {} failed\n{}", result.imported_count, result.skipped_count, result.failed_count, serde_json::to_string_pretty(&result.results).unwrap_or_default() ) }] })) } // Cookie management handlers async fn handle_import_profile_cookies( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let content = arguments .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), })?; let app_handle = { let inner = self.inner.lock().await; inner .app_handle .as_ref() .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? .clone() }; let result = crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to import cookies: {e}"), })?; if let Some(scheduler) = crate::sync::get_global_scheduler() { let profile_manager = crate::profile::manager::ProfileManager::instance(); if let Ok(profiles) = profile_manager.list_profiles() { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) { if profile.is_sync_enabled() { let pid = profile_id.to_string(); tauri::async_runtime::spawn(async move { scheduler.queue_profile_sync(pid).await; }); } } } } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "Import complete: {} imported, {} replaced, {} parse error(s)", result.cookies_imported, result.cookies_replaced, result.errors.len() ) }] })) } // VPN management handlers async fn handle_import_vpn( &self, arguments: &serde_json::Value, ) -> Result { let content = arguments .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), })?; let filename = arguments .get("filename") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing filename".to_string(), })?; let name = arguments .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), })?; let config = storage .import_config(content, filename, name) .map_err(|e| McpError { code: -32000, message: format!("Failed to import VPN config: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "VPN '{}' ({}) imported successfully with ID: {}", config.name, config.vpn_type, config.id ) }] })) } async fn handle_list_vpn_configs(&self) -> Result { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), })?; let configs = storage.list_configs().map_err(|e| McpError { code: -32000, message: format!("Failed to list VPN configs: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&configs).unwrap_or_default() }] })) } async fn handle_delete_vpn( &self, arguments: &serde_json::Value, ) -> Result { let vpn_id = arguments .get("vpn_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), })?; // First disconnect if connected (stop VPN worker) let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id).await; let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), })?; storage.delete_config(vpn_id).map_err(|e| McpError { code: -32000, message: format!("Failed to delete VPN config: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("VPN '{}' deleted successfully", vpn_id) }] })) } async fn handle_connect_vpn( &self, arguments: &serde_json::Value, ) -> Result { let vpn_id = arguments .get("vpn_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), })?; // Start VPN worker process crate::vpn_worker_runner::start_vpn_worker(vpn_id) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to connect VPN: {e}"), })?; // Update last_used timestamp { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), })?; let _ = storage.update_last_used(vpn_id); } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("VPN '{}' connected successfully", vpn_id) }] })) } async fn handle_disconnect_vpn( &self, arguments: &serde_json::Value, ) -> Result { let vpn_id = arguments .get("vpn_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), })?; crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to disconnect VPN: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("VPN '{}' disconnected successfully", vpn_id) }] })) } async fn handle_get_vpn_status( &self, arguments: &serde_json::Value, ) -> Result { let vpn_id = arguments .get("vpn_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), })?; let connected = if let Some(worker) = crate::vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id) { worker .pid .map(crate::proxy_storage::is_process_running) .unwrap_or(false) } else { false }; let status = crate::vpn::VpnStatus { connected, vpn_id: vpn_id.to_string(), connected_at: None, bytes_sent: None, bytes_received: None, last_handshake: None, }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&status).unwrap_or_default() }] })) } // Fingerprint management handlers async fn handle_get_profile_fingerprint( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; let fingerprint_info = match profile.browser.as_str() { "wayfern" => { let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default(); serde_json::json!({ "browser": "wayfern", "fingerprint": config.fingerprint, "os": config.os, "randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch, "screen_max_width": config.screen_max_width, "screen_max_height": config.screen_max_height, "screen_min_width": config.screen_min_width, "screen_min_height": config.screen_min_height, }) } _ => { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }) } }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&fingerprint_info).unwrap_or_default() }] })) } async fn handle_update_profile_fingerprint( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.can_use_cross_os_fingerprints().await { return Err(McpError { code: -32000, message: "Fingerprint editing requires a plan that includes it".to_string(), }); } let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str()); let os = arguments.get("os").and_then(|v| v.as_str()); let randomize = arguments .get("randomize_fingerprint_on_launch") .and_then(|v| v.as_bool()); if let Some(os_val) = os { if !CLOUD_AUTH.is_fingerprint_os_allowed(Some(os_val)).await { return Err(McpError { code: -32000, message: format!( "OS spoofing to '{}' requires an active Pro subscription", os_val ), }); } } let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; match profile.browser.as_str() { "wayfern" => { let mut config = profile.wayfern_config.as_ref().cloned().unwrap_or_default(); if let Some(fp) = fingerprint { config.fingerprint = Some(fp.to_string()); } if let Some(os_val) = os { config.os = Some(os_val.to_string()); } if let Some(r) = randomize { config.randomize_fingerprint_on_launch = Some(r); } ProfileManager::instance() .update_wayfern_config(app_handle.clone(), profile_id, config) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to update wayfern config: {e}"), })?; } _ => { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }) } } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Fingerprint configuration updated for profile '{}'", profile.name) }] })) } async fn handle_update_profile_proxy_bypass_rules( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let rules: Vec = arguments .get("rules") .and_then(|v| v.as_array()) .ok_or_else(|| McpError { code: -32602, message: "Missing rules array".to_string(), })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; let profile = ProfileManager::instance() .update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone()) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy bypass rules: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "Proxy bypass rules updated for profile '{}': {} rule(s) configured", profile.name, rules.len() ) }] })) } async fn handle_update_profile_dns_blocklist( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let level = arguments .get("level") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing level".to_string(), })?; let dns_blocklist = if level == "none" { None } else { Some(level.to_string()) }; let profile = ProfileManager::instance() .update_profile_dns_blocklist(profile_id, dns_blocklist) .map_err(|e| McpError { code: -32000, message: format!("Failed to update DNS blocklist: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!( "DNS blocklist updated for profile '{}': {}", profile.name, level ) }] })) } async fn handle_get_dns_blocklist_status(&self) -> Result { let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status(); Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&statuses).unwrap_or_default() }] })) } async fn handle_list_extensions(&self) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let extensions = mgr.list_extensions().map_err(|e| McpError { code: -32000, message: format!("Failed to list extensions: {e}"), })?; Ok(serde_json::to_value(extensions).unwrap()) } async fn handle_list_extension_groups(&self) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let groups = mgr.list_groups().map_err(|e| McpError { code: -32000, message: format!("Failed to list extension groups: {e}"), })?; Ok(serde_json::to_value(groups).unwrap()) } async fn handle_create_extension_group( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let name = arguments .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: name".to_string(), })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let group = mgr.create_group(name.to_string()).map_err(|e| McpError { code: -32000, message: format!("Failed to create extension group: {e}"), })?; Ok(serde_json::to_value(group).unwrap()) } async fn handle_delete_extension_mcp( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let extension_id = arguments .get("extension_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: extension_id".to_string(), })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .delete_extension_internal(extension_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete extension: {e}"), })?; Ok(serde_json::json!({"success": true})) } async fn handle_delete_extension_group_mcp( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: group_id".to_string(), })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); // For MCP, we don't have an app_handle, but we need one for sync deletion. // Use the delete_group_internal which skips sync remote deletion. mgr.delete_group_internal(group_id).map_err(|e| McpError { code: -32000, message: format!("Failed to delete extension group: {e}"), })?; if let Err(e) = crate::events::emit_empty("extensions-changed") { log::error!("Failed to emit extensions-changed event: {e}"); } Ok(serde_json::json!({"success": true})) } async fn handle_assign_extension_group_to_profile( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), }); } let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: profile_id".to_string(), })?; let extension_group_id = arguments .get("extension_group_id") .and_then(|v| v.as_str()) .map(|s| { if s.is_empty() { None } else { Some(s.to_string()) } }) .unwrap_or(None); // Validate compatibility if assigning if let Some(ref gid) = extension_group_id { let profile_manager = ProfileManager::instance(); let profiles = profile_manager.list_profiles().map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile '{profile_id}' not found"), })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .validate_group_compatibility(gid, &profile.browser) .map_err(|e| McpError { code: -32000, message: format!("{e}"), })?; } let profile_manager = ProfileManager::instance(); let profile = profile_manager .update_profile_extension_group(profile_id, extension_group_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to assign extension group: {e}"), })?; Ok(serde_json::to_value(profile).unwrap()) } async fn handle_get_team_locks(&self) -> Result { if !CLOUD_AUTH.is_on_team_plan().await { return Err(McpError { code: -32000, message: "Team features require an active team plan".to_string(), }); } let locks = crate::team_lock::TEAM_LOCK.get_locks().await; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&locks).unwrap_or_default() }] })) } async fn handle_get_team_lock_status( &self, arguments: &serde_json::Value, ) -> Result { if !CLOUD_AUTH.is_on_team_plan().await { return Err(McpError { code: -32000, message: "Team features require an active team plan".to_string(), }); } let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let lock_status = crate::team_lock::TEAM_LOCK .get_lock_status(profile_id) .await; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&lock_status).unwrap_or_default() }] })) } // --- CDP utility methods for browser interaction --- /// Where this profile's browser is: on this machine, or on the fleet. /// /// Every interaction tool goes through here, so a profile launched with /// `run-remote` is driven by exactly the tools that drive a local one. The /// alternative — a parallel set of remote-only tools — drifts from the local /// set within a release and doubles every future change. async fn resolve_cdp_target(&self, profile_id: &str) -> Result { let profile = self.get_wayfern_profile(profile_id)?; crate::cdp_target::resolve(&profile) .await .map_err(|e| McpError { code: -32000, message: e.to_string(), }) } async fn send_cdp( &self, target: &CdpTarget, method: &str, params: serde_json::Value, ) -> Result { crate::cdp_target::run_command(target, method, params) .await .map_err(cdp_error) } async fn send_human_keystrokes( &self, target: &CdpTarget, text: &str, wpm: Option, ) -> Result<(), McpError> { use crate::human_typing::{MarkovTyper, TypingAction}; let events = MarkovTyper::new(text, wpm).run(); let mut connection = target.connect().await.map_err(cdp_error)?; let mut cmd_id = 1u64; let mut last_time = 0.0; for event in &events { let delay = event.time - last_time; if delay > 0.0 { tokio::time::sleep(std::time::Duration::from_secs_f64(delay)).await; } last_time = event.time; let (down, up) = match &event.action { TypingAction::Char(ch) => { let ch = ch.to_string(); ( serde_json::json!({ "type": "keyDown", "text": ch, "key": ch, "unmodifiedText": ch, }), serde_json::json!({ "type": "keyUp", "key": ch }), ) } TypingAction::Backspace => ( serde_json::json!({ "type": "keyDown", "key": "Backspace", "code": "Backspace", "windowsVirtualKeyCode": 8, "nativeVirtualKeyCode": 8, }), serde_json::json!({ "type": "keyUp", "key": "Backspace", "code": "Backspace", "windowsVirtualKeyCode": 8, "nativeVirtualKeyCode": 8, }), ), }; for params in [down, up] { if let Err(e) = connection .send_command(cmd_id, "Input.dispatchKeyEvent", params) .await { return Err(cdp_error(e)); } // Drained rather than matched: the point is to keep reading so the // browser is never writing into a full socket while the next keystroke // is being timed. Bounded, because a reply that never comes must not // freeze typing forever — the keystroke itself was already delivered. let _ = tokio::time::timeout(KEYSTROKE_ACK_TIMEOUT, connection.next_text()).await; cmd_id += 1; } } connection.close().await; Ok(()) } /// Send a CDP command and wait for the page to finish loading. /// /// Thin over the shared runner so a local and a remote profile take exactly /// the same path: one implementation of "navigate then wait", not two that /// drift. async fn send_cdp_and_wait_for_load( &self, target: &CdpTarget, method: &str, params: serde_json::Value, timeout_secs: u64, ) -> Result { crate::cdp_target::run_command_awaiting_load(target, method, params, timeout_secs) .await .map_err(cdp_error) } /// The profile a browser-interaction tool refers to. /// /// Deliberately does NOT require a local process. That check used to live /// here, and it is exactly the state a profile running on the fleet is in, so /// it refused every remote tool call before resolution had a chance to find /// the session. Whether a browser exists at all is [`resolve_cdp_target`]'s /// answer to give, because only it can see both places one could be. fn get_wayfern_profile(&self, profile_id: &str) -> Result { let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), }); } Ok(profile) } // --- Browser interaction handlers --- async fn handle_navigate( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let url = arguments .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing url".to_string(), })?; let target = self.resolve_cdp_target(profile_id).await?; self .send_cdp_and_wait_for_load( &target, "Page.navigate", serde_json::json!({ "url": url }), 30, ) .await?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Navigated to {url}") }] })) } async fn handle_screenshot( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let format = arguments .get("format") .and_then(|v| v.as_str()) .unwrap_or("png"); let quality = arguments.get("quality").and_then(|v| v.as_i64()); let full_page = arguments .get("full_page") .and_then(|v| v.as_bool()) .unwrap_or(false); let target = self.resolve_cdp_target(profile_id).await?; let mut params = serde_json::json!({ "format": format }); if let Some(q) = quality { params["quality"] = serde_json::json!(q); } if full_page { let layout = self .send_cdp(&target, "Page.getLayoutMetrics", serde_json::json!({})) .await?; if let Some(content_size) = layout.get("contentSize") { params["clip"] = serde_json::json!({ "x": 0, "y": 0, "width": content_size.get("width").and_then(|v| v.as_f64()).unwrap_or(1920.0), "height": content_size.get("height").and_then(|v| v.as_f64()).unwrap_or(1080.0), "scale": 1 }); params["captureBeyondViewport"] = serde_json::json!(true); } } let result = self .send_cdp(&target, "Page.captureScreenshot", params) .await?; let data = result .get("data") .and_then(|v| v.as_str()) .unwrap_or_default(); Ok(serde_json::json!({ "content": [{ "type": "image", "data": data, "mimeType": format!("image/{format}") }] })) } async fn handle_evaluate_javascript( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let expression = arguments .get("expression") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing expression".to_string(), })?; let await_promise = arguments .get("await_promise") .and_then(|v| v.as_bool()) .unwrap_or(false); let wait_for_load = arguments .get("wait_for_load") .and_then(|v| v.as_bool()) .unwrap_or(false); let target = self.resolve_cdp_target(profile_id).await?; let cdp_params = serde_json::json!({ "expression": expression, "returnByValue": true, "awaitPromise": await_promise, }); let result = if wait_for_load { self .send_cdp_and_wait_for_load(&target, "Runtime.evaluate", cdp_params, 30) .await? } else { self .send_cdp(&target, "Runtime.evaluate", cdp_params) .await? }; let value = if let Some(exception) = result.get("exceptionDetails") { let text = exception .get("text") .or_else(|| { exception .get("exception") .and_then(|e| e.get("description")) }) .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); serde_json::json!({ "error": text }) } else if let Some(r) = result.get("result") { let val = r.get("value").cloned().unwrap_or(serde_json::json!(null)); serde_json::json!({ "value": val, "type": r.get("type") }) } else { serde_json::json!({ "value": null }) }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default() }] })) } async fn handle_click_element( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let selector = arguments .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing selector".to_string(), })?; let target = self.resolve_cdp_target(profile_id).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); let js = format!( r#"(() => {{ const el = document.querySelector('{}'); if (!el) throw new Error('Element not found: {}'); el.scrollIntoView({{block: 'center'}}); el.click(); return true; }})()"#, selector_escaped, selector_escaped ); // Use send_cdp_and_wait_for_load: if the click triggers navigation, // we wait for the new page to load. If not, the 10s timeout expires // and we return immediately. let result = self .send_cdp_and_wait_for_load( &target, "Runtime.evaluate", serde_json::json!({ "expression": js, "returnByValue": true, }), 10, ) .await?; if let Some(exception) = result.get("exceptionDetails") { let msg = exception .get("exception") .and_then(|e| e.get("description")) .or_else(|| exception.get("text")) .and_then(|v| v.as_str()) .unwrap_or("Click failed"); return Err(McpError { code: -32000, message: msg.to_string(), }); } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Clicked element: {selector}") }] })) } async fn handle_type_text( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let selector = arguments .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing selector".to_string(), })?; let text = arguments .get("text") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing text".to_string(), })?; let clear_first = arguments .get("clear_first") .and_then(|v| v.as_bool()) .unwrap_or(true); let instant = arguments .get("instant") .and_then(|v| v.as_bool()) .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); let target = self.resolve_cdp_target(profile_id).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); let focus_js = if clear_first { format!( r#"(() => {{ const el = document.querySelector('{}'); if (!el) throw new Error('Element not found: {}'); el.scrollIntoView({{block: 'center'}}); el.focus(); el.value = ''; el.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()"#, selector_escaped, selector_escaped ) } else { format!( r#"(() => {{ const el = document.querySelector('{}'); if (!el) throw new Error('Element not found: {}'); el.scrollIntoView({{block: 'center'}}); el.focus(); return true; }})()"#, selector_escaped, selector_escaped ) }; let focus_result = self .send_cdp( &target, "Runtime.evaluate", serde_json::json!({ "expression": focus_js, "returnByValue": true, }), ) .await?; if let Some(exception) = focus_result.get("exceptionDetails") { let msg = exception .get("exception") .and_then(|e| e.get("description")) .or_else(|| exception.get("text")) .and_then(|v| v.as_str()) .unwrap_or("Focus failed"); return Err(McpError { code: -32000, message: msg.to_string(), }); } if instant { self .send_cdp( &target, "Input.insertText", serde_json::json!({ "text": text }), ) .await?; } else { self.send_human_keystrokes(&target, text, wpm).await?; } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Typed text into element: {selector}") }] })) } async fn handle_get_page_content( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let format = arguments .get("format") .and_then(|v| v.as_str()) .unwrap_or("text"); let selector = arguments.get("selector").and_then(|v| v.as_str()); let max_chars = arguments .get("max_chars") .and_then(|v| v.as_u64()) .map(|n| n as usize) .unwrap_or(40_000); let target = self.resolve_cdp_target(profile_id).await?; let js = if let Some(sel) = selector { let sel_escaped = sel.replace('\\', "\\\\").replace('\'', "\\'"); if format == "html" { format!( r#"(() => {{ const el = document.querySelector('{}'); return el ? el.outerHTML : null; }})()"#, sel_escaped ) } else { format!( r#"(() => {{ const el = document.querySelector('{}'); return el ? el.innerText : null; }})()"#, sel_escaped ) } } else if format == "html" { "document.documentElement.outerHTML".to_string() } else { "document.body.innerText".to_string() }; let result = self .send_cdp( &target, "Runtime.evaluate", serde_json::json!({ "expression": js, "returnByValue": true, }), ) .await?; let content = result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_str()) .unwrap_or(""); // Cap output so a 500 KB DOM dump doesn't blow out the agent's context. // Slice on character boundaries (chars().take().collect()) rather than // byte indices, since the latter would panic on multi-byte boundaries. let total_chars = content.chars().count(); let (text, truncated) = if total_chars > max_chars { (content.chars().take(max_chars).collect::(), true) } else { (content.to_string(), false) }; let payload = if truncated { format!( "{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]" ) } else { text }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": payload }] })) } async fn handle_get_page_info( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let target = self.resolve_cdp_target(profile_id).await?; let result = self .send_cdp( &target, "Runtime.evaluate", serde_json::json!({ "expression": "JSON.stringify({url: location.href, title: document.title, readyState: document.readyState})", "returnByValue": true, }), ) .await?; let info_str = result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_str()) .unwrap_or("{}"); let info: serde_json::Value = serde_json::from_str(info_str).unwrap_or(serde_json::json!({})); Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&info).unwrap_or_default() }] })) } async fn handle_get_interactive_elements( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let max_chars = arguments .get("max_chars") .and_then(|v| v.as_u64()) .map(|n| n as usize) .unwrap_or(40_000); let target = self.resolve_cdp_target(profile_id).await?; // Walk the DOM for visible, non-disabled interactive elements, label them // with a zero-based index, and cache the live references on // `window.__donut_interactive` so click_by_index / type_by_index can // resolve the index → Element without round-tripping a selector. let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string()); let result = self .send_cdp( &target, "Runtime.evaluate", serde_json::json!({ "expression": js, "returnByValue": true, }), ) .await?; if let Some(exception) = result.get("exceptionDetails") { let msg = exception .get("exception") .and_then(|e| e.get("description")) .or_else(|| exception.get("text")) .and_then(|v| v.as_str()) .unwrap_or("Enumeration failed"); return Err(McpError { code: -32000, message: msg.to_string(), }); } let payload_str = result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_str()) .unwrap_or("{}"); let payload: serde_json::Value = serde_json::from_str(payload_str).unwrap_or(serde_json::json!({})); let elements = payload .get("elements") .and_then(|v| v.as_str()) .unwrap_or(""); let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0); let truncated = payload .get("truncated") .and_then(|v| v.as_bool()) .unwrap_or(false); let header = if truncated { format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):") } else { format!("{count} interactive elements:") }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("{header}\n{elements}") }] })) } async fn handle_click_by_index( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let index = arguments .get("index") .and_then(|v| v.as_u64()) .ok_or_else(|| McpError { code: -32602, message: "Missing index".to_string(), })?; let target = self.resolve_cdp_target(profile_id).await?; let js = format!( r#"(() => {{ const arr = window.__donut_interactive; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); el.click(); return true; }})()"# ); let result = self .send_cdp_and_wait_for_load( &target, "Runtime.evaluate", serde_json::json!({ "expression": js, "returnByValue": true, }), 10, ) .await?; if let Some(exception) = result.get("exceptionDetails") { let msg = exception .get("exception") .and_then(|e| e.get("description")) .or_else(|| exception.get("text")) .and_then(|v| v.as_str()) .unwrap_or("Click failed"); return Err(McpError { code: -32000, message: msg.to_string(), }); } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Clicked element at index {index}") }] })) } async fn handle_type_by_index( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), })?; let index = arguments .get("index") .and_then(|v| v.as_u64()) .ok_or_else(|| McpError { code: -32602, message: "Missing index".to_string(), })?; let text = arguments .get("text") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing text".to_string(), })?; let clear_first = arguments .get("clear_first") .and_then(|v| v.as_bool()) .unwrap_or(true); let instant = arguments .get("instant") .and_then(|v| v.as_bool()) .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); let target = self.resolve_cdp_target(profile_id).await?; // Mirrors handle_type_text's focus step but resolves the element via the // cached index instead of a CSS selector. let focus_js = if clear_first { format!( r#"(() => {{ const arr = window.__donut_interactive; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); el.focus(); el.value = ''; el.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()"# ) } else { format!( r#"(() => {{ const arr = window.__donut_interactive; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); el.focus(); return true; }})()"# ) }; let focus_result = self .send_cdp( &target, "Runtime.evaluate", serde_json::json!({ "expression": focus_js, "returnByValue": true, }), ) .await?; if let Some(exception) = focus_result.get("exceptionDetails") { let msg = exception .get("exception") .and_then(|e| e.get("description")) .or_else(|| exception.get("text")) .and_then(|v| v.as_str()) .unwrap_or("Focus failed"); return Err(McpError { code: -32000, message: msg.to_string(), }); } if instant { self .send_cdp( &target, "Input.insertText", serde_json::json!({ "text": text }), ) .await?; } else { self.send_human_keystrokes(&target, text, wpm).await?; } Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Typed text into element at index {index}") }] })) } // --- Synchronizer handlers --- async fn handle_start_sync_session( &self, arguments: &serde_json::Value, ) -> Result { let leader_id = arguments .get("leader_profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing leader_profile_id".to_string(), })?; let follower_ids: Vec = arguments .get("follower_profile_ids") .and_then(|v| v.as_array()) .ok_or_else(|| McpError { code: -32602, message: "Missing follower_profile_ids".to_string(), })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); let app = { let inner = self.inner.lock().await; inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? }; let info = crate::synchronizer::SynchronizerManager::instance() .start_session(app, leader_id.to_string(), follower_ids) .await .map_err(|e| McpError { code: -32000, message: e, })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&info).unwrap_or_default() }] })) } async fn handle_stop_sync_session( &self, arguments: &serde_json::Value, ) -> Result { let session_id = arguments .get("session_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing session_id".to_string(), })?; let app = { let inner = self.inner.lock().await; inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? }; crate::synchronizer::SynchronizerManager::instance() .stop_session(app, session_id) .await .map_err(|e| McpError { code: -32000, message: e, })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": "Sync session stopped" }] })) } async fn handle_get_sync_sessions(&self) -> Result { let sessions = crate::synchronizer::SynchronizerManager::instance() .get_sessions() .await; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&sessions).unwrap_or_default() }] })) } async fn handle_remove_sync_follower( &self, arguments: &serde_json::Value, ) -> Result { let session_id = arguments .get("session_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing session_id".to_string(), })?; let follower_id = arguments .get("follower_profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing follower_profile_id".to_string(), })?; let app = { let inner = self.inner.lock().await; inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? }; crate::synchronizer::SynchronizerManager::instance() .remove_follower(app, session_id, follower_id) .await .map_err(|e| McpError { code: -32000, message: e, })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": "Follower removed from sync session" }] })) } // --- Remote fleet and cookie bot ----------------------------------------- // // Every tool below is a proxy onto Donut cloud, which owns the schedule, the // calendar arithmetic, the browsing behaviour and the pooled hour budget. // Nothing here decides when a run happens or what it does. What this file // DOES decide is which profiles may be offered to the bot at all. /// Render a value as the single text block an MCP tool answers with. fn json_content(value: &T) -> Result { let text = serde_json::to_string_pretty(value).map_err(|e| McpError { code: -32000, message: format!("Failed to encode response: {e}"), })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] })) } fn require_str<'a>(arguments: &'a serde_json::Value, key: &str) -> Result<&'a str, McpError> { arguments .get(key) .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()) .ok_or_else(|| McpError { code: -32602, message: format!("Missing {key}"), }) } /// Read a whole-number argument, refusing anything that would silently wrap. /// /// `as_u64() as u16` would turn a run time of 1440 into 1440 but 65536 into /// 0, quietly scheduling a run at midnight nobody asked for. fn require_u16(arguments: &serde_json::Value, key: &str) -> Result { Self::optional_u16(arguments, key)?.ok_or_else(|| McpError { code: -32602, message: format!("Missing {key}"), }) } fn optional_u16(arguments: &serde_json::Value, key: &str) -> Result, McpError> { let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else { return Ok(None); }; value .as_u64() .and_then(|raw| u16::try_from(raw).ok()) .map(Some) .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 65535"), }) } fn optional_u8(arguments: &serde_json::Value, key: &str) -> Result, McpError> { let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else { return Ok(None); }; value .as_u64() .and_then(|raw| u8::try_from(raw).ok()) .map(Some) .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 255"), }) } fn optional_u32(arguments: &serde_json::Value, key: &str) -> Result, McpError> { let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else { return Ok(None); }; value .as_u64() .and_then(|raw| u32::try_from(raw).ok()) .map(Some) .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 4294967295"), }) } /// A cloud failure, rendered as the `{"code":…,"params":{…}}` envelope. /// /// The backend's own English would be meaningless to an agent deciding what /// to do next; a stable code and its parameters are something it can branch /// on, and it is the same envelope the desktop and the REST API answer with. fn cloud_error(err: crate::cookie_bot::CookieBotError) -> McpError { McpError { code: -32000, message: err.to_error_json(), } } /// Resolve a profile the cookie bot is allowed to touch. /// /// The same gate the REST surface applies, for the same reason: the bot runs /// ONLY on the leased fleet, so a profile that cannot make the round trip to /// a remote host and back — never synced, encrypted with a key that never /// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no /// proxy or VPN to egress through — must never reach an enrolment, a quota /// check or a leased host on ANY surface. fn cookie_bot_eligible_profile(profile_id: &str) -> Result { let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), })?; let profile = profiles .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), })?; crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) .map_err(|message| McpError { code: -32000, message, })?; Ok(profile) } /// Start this profile on a host of its own operating system. /// /// Deliberately no `is_cross_os` guard: local `run_profile` refuses a foreign /// profile because THIS machine is the wrong OS, and running it on a host of /// its own OS is precisely what this exists for. async fn handle_run_profile_remote( &self, arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; let url = arguments .get("url") .and_then(|v| v.as_str()) .map(str::to_string); let profile = self.get_wayfern_profile(profile_id)?; // The host pulls the profile from cloud storage, so one that has never // synced would launch an empty browser and push that emptiness back over // the real one. Same rule the REST route applies, from the same place. crate::api_server::remote_launch_precondition(&profile) .await .map_err(|message| McpError { code: -32000, message, })?; let app = { let inner = self.inner.lock().await; inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })? }; let outcome = crate::remote_session::start_remote_session(app, &profile, url) .await .map_err(|e| McpError { code: -32000, message: e.to_error_json(), })?; Self::json_content(&outcome) } /// Stop a remote session and settle what it cost. async fn handle_stop_remote_session( arguments: &serde_json::Value, ) -> Result { let session_id = Self::require_str(arguments, "session_id")?; let outcome = crate::remote_session::end_remote_session(session_id) .await .map_err(|e| McpError { code: -32000, message: e.to_error_json(), })?; Self::json_content(&outcome) } async fn handle_list_remote_sessions() -> Result { let sessions = crate::remote_session::list_remote_sessions() .await .map_err(|e| McpError { code: -32000, message: e.to_error_json(), })?; Self::json_content(&sessions) } async fn handle_get_remote_session( arguments: &serde_json::Value, ) -> Result { let session_id = Self::require_str(arguments, "session_id")?; let state = crate::remote_session::get_remote_session(session_id) .await .map_err(|e| McpError { code: -32000, message: e.to_error_json(), })?; Self::json_content(&state) } async fn handle_get_remote_hours_quota() -> Result { let quota = crate::cookie_bot::remote_hours_quota() .await .map_err(Self::cloud_error)?; Self::json_content("a) } async fn handle_list_cookie_bot_schedules( arguments: &serde_json::Value, ) -> Result { let scope = arguments.get("scope").and_then(|value| value.as_str()); let schedules = crate::cookie_bot::list_schedules(scope) .await .map_err(Self::cloud_error)?; Self::json_content(&schedules) } async fn handle_get_cookie_bot_schedule( arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; // Not gated on eligibility: a profile whose sync was turned off after it // was enrolled must still be able to show what it is enrolled as. let schedule = crate::cookie_bot::get_schedule(profile_id) .await .map_err(Self::cloud_error)?; Self::json_content(&schedule) } async fn handle_set_cookie_bot_schedule( arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; let profile = Self::cookie_bot_eligible_profile(profile_id)?; // `bot_precondition` already proved the profile has an OS the fleet can // lease. Taking the platform from the profile rather than the arguments is // what stops an agent enrolling a macOS profile onto a Windows host. let platform = profile .resolved_os() .ok_or_else(|| McpError { code: -32000, message: "Profile has no recorded operating system".to_string(), })? .to_string(); if let Some(requested) = arguments.get("platform").and_then(|v| v.as_str()) { if requested != platform { return Err(McpError { code: -32602, message: format!( "platform {requested:?} does not match the profile's own operating system {platform:?}" ), }); } } let enabled = arguments .get("enabled") .and_then(|value| value.as_bool()) .ok_or_else(|| McpError { code: -32602, message: "Missing enabled".to_string(), })?; let sites = arguments .get("sites") .and_then(|value| value.as_array()) .map(|items| { items .iter() .filter_map(|item| item.as_str().map(str::to_string)) .collect::>() }) .unwrap_or_default(); let input = crate::cookie_bot::CookieBotScheduleInput { profile_name: arguments .get("profile_name") .and_then(|value| value.as_str()) .map_or_else(|| profile.name.clone(), str::to_string), platform, enabled, run_at_minute: Self::require_u16(arguments, "run_at_minute")?, days_mask: Self::optional_u8(arguments, "days_mask")?.ok_or_else(|| McpError { code: -32602, message: "Missing days_mask".to_string(), })?, timezone: Self::require_str(arguments, "timezone")?.to_string(), preset: Self::require_str(arguments, "preset")?.to_string(), max_minutes: Self::optional_u32(arguments, "max_minutes")?.ok_or_else(|| McpError { code: -32602, message: "Missing max_minutes".to_string(), })?, sites, jitter_seconds: Self::optional_u32(arguments, "jitter_seconds")?, ..Default::default() } // Derived from the profile, never from the tool arguments: an agent must not // be able to claim a profile has a proxy when it does not. .with_profile_state(crate::cookie_bot::profile_state(&profile)); let acknowledge_conflict = arguments .get("acknowledge_conflict") .and_then(|value| value.as_bool()) .unwrap_or(false); let saved = crate::cookie_bot::save_schedule(profile_id, &input, acknowledge_conflict) .await .map_err(Self::cloud_error)?; Self::json_content(&saved) } async fn handle_delete_cookie_bot_schedule( arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; // No eligibility gate: a profile that has since become ineligible is // exactly the one an agent most needs to be able to unenrol. let deleted = crate::cookie_bot::delete_schedule(profile_id) .await .map_err(Self::cloud_error)?; Self::json_content(&deleted) } async fn handle_check_cookie_bot_conflicts( arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; let conflicts = crate::cookie_bot::check_conflicts( profile_id, Self::optional_u16(arguments, "run_at_minute")?, arguments.get("timezone").and_then(|value| value.as_str()), Self::optional_u8(arguments, "days_mask")?, ) .await .map_err(Self::cloud_error)?; Self::json_content(&conflicts) } async fn handle_list_cookie_bot_runs( arguments: &serde_json::Value, ) -> Result { let runs = crate::cookie_bot::list_runs( arguments.get("profile_id").and_then(|value| value.as_str()), arguments.get("scope").and_then(|value| value.as_str()), Self::optional_u32(arguments, "limit")?, arguments.get("before").and_then(|value| value.as_str()), ) .await .map_err(Self::cloud_error)?; Self::json_content(&runs) } async fn handle_run_cookie_bot_now( arguments: &serde_json::Value, ) -> Result { let profile_id = Self::require_str(arguments, "profile_id")?; Self::cookie_bot_eligible_profile(profile_id)?; let started = crate::cookie_bot::run_now(profile_id, Self::optional_u32(arguments, "max_minutes")?) .await .map_err(Self::cloud_error)?; Self::json_content(&started) } async fn handle_cancel_cookie_bot_run( arguments: &serde_json::Value, ) -> Result { let run_id = Self::require_str(arguments, "run_id")?; let run = crate::cookie_bot::cancel_run(run_id) .await .map_err(Self::cloud_error)?; Self::json_content(&run) } async fn handle_list_cookie_bot_presets() -> Result { // Ids and a rough duration only. What a preset expands to — the site // ordering, the dwell model, the scroll and click programme — is the // server's, and stays there. let presets = crate::cookie_bot::list_presets() .await .map_err(Self::cloud_error)?; Self::json_content(&presets) } async fn handle_get_cookie_bot_usage( arguments: &serde_json::Value, ) -> Result { let usage = crate::cookie_bot::team_usage(arguments.get("period").and_then(|v| v.as_str())) .await .map_err(Self::cloud_error)?; Self::json_content(&usage) } } lazy_static::lazy_static! { static ref MCP_SERVER: McpServer = McpServer::new(); } #[cfg(test)] mod tests { use super::*; #[test] fn test_mcp_tools_count() { let server = McpServer::new(); let tools = server.get_tools(); // Should have at least 54 tools (34 + 7 browser interaction + 13 remote // fleet and cookie-bot tools) assert!(tools.len() >= 54); // Names are the contract an MCP client is written against, so a duplicate // silently shadows one of the two in dispatch and the tool that loses is // simply never reachable. let mut seen = std::collections::HashSet::new(); for tool in &tools { assert!( seen.insert(tool.name.as_str()), "duplicate MCP tool name: {}", tool.name ); } // Check tool names let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); // Profile tools assert!(tool_names.contains(&"list_profiles")); assert!(tool_names.contains(&"get_profile")); assert!(tool_names.contains(&"run_profile")); assert!(tool_names.contains(&"kill_profile")); assert!(tool_names.contains(&"get_profile_status")); // Profile import tools assert!(tool_names.contains(&"detect_browser_profiles")); assert!(tool_names.contains(&"import_browser_profiles")); // Group tools assert!(tool_names.contains(&"list_groups")); assert!(tool_names.contains(&"get_group")); assert!(tool_names.contains(&"create_group")); assert!(tool_names.contains(&"update_group")); assert!(tool_names.contains(&"delete_group")); assert!(tool_names.contains(&"assign_profiles_to_group")); // Proxy tools assert!(tool_names.contains(&"list_proxies")); assert!(tool_names.contains(&"get_proxy")); assert!(tool_names.contains(&"create_proxy")); assert!(tool_names.contains(&"update_proxy")); assert!(tool_names.contains(&"delete_proxy")); // Proxy import/export tools assert!(tool_names.contains(&"export_proxies")); assert!(tool_names.contains(&"import_proxies")); // VPN tools assert!(tool_names.contains(&"import_vpn")); assert!(tool_names.contains(&"list_vpn_configs")); assert!(tool_names.contains(&"delete_vpn")); assert!(tool_names.contains(&"connect_vpn")); assert!(tool_names.contains(&"disconnect_vpn")); assert!(tool_names.contains(&"get_vpn_status")); // Fingerprint tools assert!(tool_names.contains(&"get_profile_fingerprint")); assert!(tool_names.contains(&"update_profile_fingerprint")); assert!(tool_names.contains(&"update_profile_proxy_bypass_rules")); // Extension tools assert!(tool_names.contains(&"list_extensions")); assert!(tool_names.contains(&"list_extension_groups")); assert!(tool_names.contains(&"create_extension_group")); assert!(tool_names.contains(&"delete_extension")); assert!(tool_names.contains(&"delete_extension_group")); assert!(tool_names.contains(&"assign_extension_group_to_profile")); // Cookie tools assert!(tool_names.contains(&"import_profile_cookies")); // Team lock tools assert!(tool_names.contains(&"get_team_locks")); assert!(tool_names.contains(&"get_team_lock_status")); // Synchronizer tools assert!(tool_names.contains(&"start_sync_session")); assert!(tool_names.contains(&"stop_sync_session")); assert!(tool_names.contains(&"get_sync_sessions")); assert!(tool_names.contains(&"remove_sync_follower")); // Browser interaction tools assert!(tool_names.contains(&"navigate")); assert!(tool_names.contains(&"screenshot")); assert!(tool_names.contains(&"evaluate_javascript")); assert!(tool_names.contains(&"click_element")); assert!(tool_names.contains(&"type_text")); assert!(tool_names.contains(&"get_page_content")); assert!(tool_names.contains(&"get_page_info")); // Remote fleet: an agent must be able to start a session, see it become // usable, drive it with the tools above, and stop it. Any one of those // missing makes remote driving unusable from MCP alone. assert!(tool_names.contains(&"run_profile_remote")); assert!(tool_names.contains(&"stop_remote_session")); assert!(tool_names.contains(&"list_remote_sessions")); assert!(tool_names.contains(&"get_remote_session")); assert!(tool_names.contains(&"get_remote_hours_quota")); // Cookie bot assert!(tool_names.contains(&"list_cookie_bot_schedules")); assert!(tool_names.contains(&"get_cookie_bot_schedule")); assert!(tool_names.contains(&"set_cookie_bot_schedule")); assert!(tool_names.contains(&"delete_cookie_bot_schedule")); assert!(tool_names.contains(&"check_cookie_bot_conflicts")); assert!(tool_names.contains(&"list_cookie_bot_runs")); assert!(tool_names.contains(&"run_cookie_bot_now")); assert!(tool_names.contains(&"cancel_cookie_bot_run")); assert!(tool_names.contains(&"list_cookie_bot_presets")); assert!(tool_names.contains(&"get_cookie_bot_usage")); } // A tool advertised in tools/list but missing from dispatch answers "Unknown // tool": the client can see it and cannot call it, and nothing else in the // build notices. // // Asserted against the source rather than by dispatching, because half these // tools take no arguments — calling them would reach Donut cloud, and a unit // test that needs the network is a test that gets deleted. #[test] fn every_cookie_bot_tool_is_both_advertised_and_dispatchable() { let server = McpServer::new(); let advertised: Vec = server .get_tools() .into_iter() .map(|tool| tool.name) .filter(|name| name.contains("cookie_bot") || name.contains("remote")) .collect(); let dispatched = include_str!("mcp_server.rs"); for name in &advertised { assert!( dispatched.contains(&format!("\"{name}\" =>")), "tool is advertised but has no dispatch arm: {name}" ); } assert_eq!( advertised.len(), 15, "expected the full remote-fleet and cookie-bot set: {advertised:?}" ); } // The bot runs ONLY on the leased fleet. A profile that cannot be // materialised on a remote host has no path to a run, and every write tool // resolves its profile through this gate before the cloud is asked, so there // is no argument shape that points the bot at a local-only profile. #[test] fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() { use crate::profile::types::SyncMode; let eligible = || BrowserProfile { id: uuid::Uuid::nil(), name: "warm me".to_string(), browser: "wayfern".to_string(), version: "latest".to_string(), sync_mode: SyncMode::Regular, host_os: Some("macos".to_string()), proxy_id: Some("proxy-1".to_string()), ..Default::default() }; assert!(crate::cookie_bot::bot_precondition( &eligible(), &crate::remote_exit::ExitReachability::Remote ) .is_ok()); let mut local_only = eligible(); local_only.sync_mode = SyncMode::Disabled; assert!( crate::cookie_bot::bot_precondition( &local_only, &crate::remote_exit::ExitReachability::Remote ) .is_err(), "a profile with no cloud copy has nothing for a host to open" ); let mut linux = eligible(); linux.host_os = Some("linux".to_string()); assert!( crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote) .is_err(), "the fleet cannot lease a linux host" ); let mut datacenter_egress = eligible(); datacenter_egress.proxy_id = None; datacenter_egress.vpn_id = None; assert!( crate::cookie_bot::bot_precondition( &datacenter_egress, &crate::remote_exit::ExitReachability::None ) .is_err(), "hours of traffic from a hosting ASN damages the identity being warmed" ); } // Enrolment carries only the user's own scalars. A site list, a dwell range // or a step programme appearing in the schema would mean the browsing model // had leaked out of the server and into this AGPL client. #[test] fn the_bot_tools_expose_choices_not_behaviour() { let server = McpServer::new(); let tools = server.get_tools(); let presets = tools .iter() .find(|tool| tool.name == "list_cookie_bot_presets") .expect("list_cookie_bot_presets tool"); assert_eq!( presets.input_schema["properties"] .as_object() .map(serde_json::Map::len), Some(0), "a preset is chosen by id; it takes no behaviour parameters" ); let set = tools .iter() .find(|tool| tool.name == "set_cookie_bot_schedule") .expect("set_cookie_bot_schedule tool"); let properties = set.input_schema["properties"] .as_object() .expect("schedule properties"); for leaked in [ "dwell", "dwell_seconds", "scroll", "clicks", "steps", "actions", "corpus", "user_agent", ] { assert!( !properties.contains_key(leaked), "the browsing model leaked into the tool contract: {leaked}" ); } // `platform` is accepted but not required: this machine already knows the // profile's operating system, and a supplied one that disagrees is // refused rather than honoured. let required = set.input_schema["required"] .as_array() .expect("required fields"); assert!(!required.iter().any(|field| field == "platform")); assert!(required.iter().any(|field| field == "profile_id")); assert!(required.iter().any(|field| field == "preset")); } #[test] fn test_mcp_server_initial_state() { let server = McpServer::new(); assert!(!server.is_running()); } #[test] fn proxy_tool_schema_exposes_vless_reality_without_requiring_regular_endpoint_fields() { let server = McpServer::new(); let tools = server.get_tools(); let create = tools .iter() .find(|tool| tool.name == "create_proxy") .expect("create_proxy tool"); let properties = &create.input_schema["properties"]; assert!(properties["proxy_type"]["enum"] .as_array() .is_some_and(|values| values.iter().any(|value| value == "vless"))); assert!(properties["vless_uri"].is_object()); let required = create.input_schema["required"] .as_array() .expect("required fields"); assert!(required.iter().any(|field| field == "name")); assert!(required.iter().any(|field| field == "proxy_type")); assert!(!required.iter().any(|field| field == "host")); assert!(!required.iter().any(|field| field == "port")); } #[test] fn rate_limit_only_classifies_browser_automation_tools() { let request = |method: &str, name: Option<&str>| McpRequest { jsonrpc: "2.0".to_string(), id: Some(serde_json::json!(1)), method: method.to_string(), params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })), }; for name in [ "run_profile", "kill_profile", "batch_run_profiles", "batch_stop_profiles", "start_sync_session", "navigate", "screenshot", "evaluate_javascript", "click_element", "type_text", "get_page_content", "get_page_info", "get_interactive_elements", "click_by_index", "type_by_index", // Leases a remote host for up to two hours and spends the pooled // remote-hour budget. "run_cookie_bot_now", "run_profile_remote", // Reaches the fleet, like the remote-session stop it mirrors. "cancel_cookie_bot_run", "stop_remote_session", ] { assert!( McpServer::is_automation_tool_call(&request("tools/call", Some(name))), "automation tool was not limited: {name}" ); } for name in [ "list_profiles", // Configuration, not automation: one row in Donut cloud, no hardware // leased. Metering it would throttle an agent enrolling a fleet of // profiles, while the budget that guards the hardware is spent per run. "set_cookie_bot_schedule", "delete_cookie_bot_schedule", "list_cookie_bot_schedules", "get_cookie_bot_schedule", "check_cookie_bot_conflicts", "list_cookie_bot_runs", "list_cookie_bot_presets", "get_cookie_bot_usage", "get_remote_hours_quota", "list_remote_sessions", "get_remote_session", ] { assert!( !McpServer::is_automation_tool_call(&request("tools/call", Some(name))), "free or non-leasing tool was limited: {name}" ); } assert!(!McpServer::is_automation_tool_call(&request( "tools/list", None ))); } }