Files
donutbrowser/src-tauri/src/mcp_server.rs
T
2026-08-06 14:39:53 -07:00

6369 lines
200 KiB
Rust

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 + '</' + tag + '>';
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<serde_json::Value>,
method: String,
params: Option<serde_json::Value>,
}
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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<McpError>,
}
#[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<AppHandle>,
token: Option<String>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
sessions: HashMap<String, McpSession>,
}
#[derive(Clone)]
struct McpHttpState {
server: &'static McpServer,
token: String,
}
pub struct McpServer {
inner: Arc<AsyncMutex<McpServerInner>>,
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<u16> {
let port = self.port.load(Ordering::SeqCst);
if port > 0 {
Some(port)
} else {
None
}
}
pub async fn start(&self, app_handle: AppHandle) -> Result<u16, String> {
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<TcpListener, String> {
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::<u16>() % 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<McpHttpState>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
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<McpHttpState>,
req: Request<Body>,
) -> 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<McpHttpState>, req: Request<Body>) -> 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<McpTool> {
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<serde_json::Value, McpError> {
Ok(serde_json::json!({
"tools": self.get_tools()
}))
}
async fn handle_tool_call(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, McpError> {
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("<none>");
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
// 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<serde_json::Value, McpError> {
// 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<serde_json::Value, McpError> {
Self::require_capability(
"Batch launching profiles",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_ids: Vec<String> = 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<String> = 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<serde_json::Value, McpError> {
Self::require_capability(
"Batch stopping profiles",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_ids: Vec<String> = 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<String> = 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<serde_json::Value, McpError> {
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<Vec<String>> = 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<serde_json::Value, McpError> {
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<String> = 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<String> = 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
let profile_ids: Vec<String> = 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
let items: Vec<crate::profile_importer::ImportProfileItem> = 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::<crate::profile_importer::DuplicateStrategy>)
.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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<String> = 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<CdpTarget, McpError> {
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<serde_json::Value, McpError> {
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<f64>,
) -> 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<serde_json::Value, McpError> {
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<BrowserProfile, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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::<String>(), 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<String> = 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<T: Serialize>(value: &T) -> Result<serde_json::Value, McpError> {
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<u16, McpError> {
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<Option<u16>, 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<Option<u8>, 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<Option<u32>, 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<BrowserProfile, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
let quota = crate::cookie_bot::remote_hours_quota()
.await
.map_err(Self::cloud_error)?;
Self::json_content(&quota)
}
async fn handle_list_cookie_bot_schedules(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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::<Vec<_>>()
})
.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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
// 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<serde_json::Value, McpError> {
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<String> = 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
)));
}
}