Files
donutbrowser/src-tauri/src/mcp_server.rs
T

4459 lines
133 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::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)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
#[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-03-26";
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,
}
const DEFAULT_MCP_PORT: u16 = 51080;
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)
}
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
});
}
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(
"Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(),
);
}
if self.is_running() {
return Err("MCP server is already running".to_string());
}
let settings_manager = SettingsManager::instance();
let settings = settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {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| format!("Failed to generate MCP token: {e}"))?
};
// Determine port (use saved port, or try default, or random)
let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT);
let actual_port = self.bind_to_available_port(preferred_port).await?;
// 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| format!("Failed to save settings: {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(actual_port, 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<u16, String> {
let addr = SocketAddr::from(([127, 0, 0, 1], preferred));
if TcpListener::bind(addr).await.is_ok() {
return Ok(preferred);
}
// Try random ports in 51000-51999 range
for _ in 0..10 {
let port = 51000 + (rand::random::<u16>() % 1000);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if TcpListener::bind(addr).await.is_ok() {
return Ok(port);
}
}
Err("Could not find available port for MCP server".to_string())
}
async fn run_http_server(
port: u16,
state: McpHttpState,
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let app = Router::new()
.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 addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = match TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
log::error!("[mcp] Failed to bind to port {}: {}", port, e);
return;
}
};
log::info!(
"[mcp] HTTP server listening on http://127.0.0.1:{}/mcp",
port
);
let server = axum::serve(listener, app).with_graceful_shutdown(async {
let _ = shutdown_rx.await;
log::info!("[mcp] HTTP server shutting down");
});
if let Err(e) = server.await {
log::error!("[mcp] HTTP server error: {}", e);
}
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Health endpoint is public
if req.uri().path() == "/health" {
return Ok(next.run(req).await);
}
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let token = auth_header.and_then(|h| h.strip_prefix("Bearer "));
if token != Some(&state.token) {
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();
}
}
let response = state.server.handle_request(request).await;
Json(response).into_response()
}
}
pub async fn stop(&self) -> Result<(), String> {
if !self.is_running() {
return Err("MCP server is not running".to_string());
}
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 and Camoufox 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".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".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: "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", "camoufox"],
"description": "Browser engine to use"
},
"proxy_id": {
"type": "string",
"description": "Optional proxy UUID to assign"
},
"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: "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)"
},
"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)"
}
},
"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 if a browser profile is currently running".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. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".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"],
"description": "The type of proxy (for regular proxies)"
},
"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)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)"
}
},
"required": ["name"]
}),
},
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"],
"description": "The type of proxy (for regular proxies)"
},
"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)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response (for dynamic proxies)"
}
},
"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) or OpenVPN (.ovpn) configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Raw VPN config file content"
},
"filename": {
"type": "string",
"description": "Original filename (.conf or .ovpn) for type detection"
},
"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 or Camoufox 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 or Camoufox 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: "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"]
}),
},
// 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"]
}),
},
]
}
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!({}));
match tool_name {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_run_profile(&arguments).await
}
"kill_profile" => self.handle_kill_profile(&arguments).await,
"create_profile" => self.handle_create_profile(&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
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(&arguments)
.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
}
// 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_paid_subscription("Synchronizer").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_paid_subscription("Browser automation").await?;
self.handle_navigate(&arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_screenshot(&arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_evaluate_javascript(&arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_element(&arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_text(&arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_content(&arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(&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 and Camoufox profiles
let filtered: Vec<&BrowserProfile> = profiles
.iter()
.filter(|p| p.browser == "wayfern" || p.browser == "camoufox")
.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 or Camoufox profile
if profile.browser != "wayfern" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox 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> {
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 or Camoufox profile
if profile.browser != "wayfern" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox 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 the browser
crate::browser_runner::BrowserRunner::instance()
.launch_browser(
app_handle.clone(),
profile,
url.map(|s| s.to_string()),
None,
)
.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> {
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 or Camoufox profile
if profile.browser != "wayfern" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox 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_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" && browser != "camoufox" {
return Err(McpError {
code: -32602,
message: "browser must be 'wayfern' or 'camoufox'".to_string(),
});
}
let proxy_id = arguments
.get("proxy_id")
.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, None, group_id, false,
)
.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(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}"),
})?;
}
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 or Camoufox profile
if profile.browser != "wayfern" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
});
}
let is_running = profile.process_id.is_some();
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::json!({
"profile_id": profile_id,
"is_running": is_running
}).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(),
})?;
// Check if this is a dynamic proxy creation
let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str());
let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) {
PROXY_MANAGER
.create_dynamic_proxy(
app_handle,
name.to_string(),
url.to_string(),
format.to_string(),
)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create dynamic proxy: {e}"),
})?
} else {
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type (required for regular proxies)".to_string(),
})?;
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host (required for regular proxies)".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port (required for regular proxies)".to_string(),
})? as u16;
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: host.to_string(),
port,
username,
password,
};
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();
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());
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 = arguments
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or(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());
Some(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
} 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(),
})?;
// Check for dynamic proxy fields
let dynamic_url = arguments
.get("dynamic_proxy_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some();
let proxy = if is_dynamic {
PROXY_MANAGER
.update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update dynamic proxy: {e}"),
})?
} else {
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()
)
}]
}))
}
// 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() {
"camoufox" => {
let config = profile
.camoufox_config
.as_ref()
.cloned()
.unwrap_or_default();
serde_json::json!({
"browser": "camoufox",
"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,
})
}
"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 and Camoufox 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.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Fingerprint editing 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 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() {
"camoufox" => {
let mut config = profile
.camoufox_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_camoufox_config(app_handle.clone(), profile_id, config)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update camoufox config: {e}"),
})?;
}
"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 and Camoufox 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_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 ---
async fn get_cdp_port_for_profile(&self, profile: &BrowserProfile) -> Result<u16, McpError> {
let profiles_dir = ProfileManager::instance().get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_path.to_string_lossy();
// Retry a few times — port info may not be stored yet right after launch
for attempt in 0..10 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
let port = if profile.browser == "wayfern" {
crate::wayfern_manager::WayfernManager::instance()
.get_cdp_port(&profile_path_str)
.await
} else if profile.browser == "camoufox" {
crate::camoufox_manager::CamoufoxManager::instance()
.get_cdp_port(&profile_path_str)
.await
} else {
None
};
if let Some(p) = port {
return Ok(p);
}
}
Err(McpError {
code: -32000,
message: format!(
"No CDP connection available for profile '{}'. Make sure the browser is running.",
profile.name
),
})
}
async fn get_cdp_ws_url(&self, port: u16) -> Result<String, McpError> {
let url = format!("http://127.0.0.1:{port}/json");
let client = reqwest::Client::new();
// Retry connecting to CDP endpoint (browser may still be starting up)
let max_attempts = 15;
let mut last_err = String::new();
for attempt in 0..max_attempts {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
match client
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await
{
Ok(resp) => match resp.json::<Vec<serde_json::Value>>().await {
Ok(targets) => {
if let Some(ws_url) = targets
.iter()
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("page"))
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(|v| v.as_str())
{
return Ok(ws_url.to_string());
}
last_err = "No page target found in browser".to_string();
}
Err(e) => {
last_err = format!("Failed to parse CDP targets: {e}");
}
},
Err(e) => {
last_err = format!("Failed to connect to browser CDP endpoint: {e}");
}
}
}
Err(McpError {
code: -32000,
message: last_err,
})
}
async fn send_cdp(
&self,
ws_url: &str,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, McpError> {
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect to CDP WebSocket: {e}"),
})?;
let command = serde_json::json!({
"id": 1,
"method": method,
"params": params
});
ws_stream
.send(Message::Text(command.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send CDP command: {e}"),
})?;
while let Some(msg) = ws_stream.next().await {
let msg = msg.map_err(|e| McpError {
code: -32000,
message: format!("CDP WebSocket error: {e}"),
})?;
if let Message::Text(text) = msg {
let response: serde_json::Value =
serde_json::from_str(text.as_str()).map_err(|e| McpError {
code: -32000,
message: format!("Failed to parse CDP response: {e}"),
})?;
if response.get("id") == Some(&serde_json::json!(1)) {
if let Some(error) = response.get("error") {
return Err(McpError {
code: -32000,
message: format!("CDP error: {error}"),
});
}
return Ok(
response
.get("result")
.cloned()
.unwrap_or(serde_json::json!({})),
);
}
}
}
Err(McpError {
code: -32000,
message: "No response received from CDP".to_string(),
})
}
async fn send_human_keystrokes(
&self,
ws_url: &str,
text: &str,
wpm: Option<f64>,
) -> Result<(), McpError> {
use crate::human_typing::{MarkovTyper, TypingAction};
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
let events = MarkovTyper::new(text, wpm).run();
let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect to CDP WebSocket: {e}"),
})?;
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;
match &event.action {
TypingAction::Char(ch) => {
let text_str = ch.to_string();
// keyDown
let down = serde_json::json!({
"id": cmd_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyDown",
"text": text_str,
"key": text_str,
"unmodifiedText": text_str,
}
});
cmd_id += 1;
ws_stream
.send(Message::Text(down.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send key event: {e}"),
})?;
// Drain response
let _ = ws_stream.next().await;
// keyUp
let up = serde_json::json!({
"id": cmd_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyUp",
"key": text_str,
}
});
cmd_id += 1;
ws_stream
.send(Message::Text(up.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send key event: {e}"),
})?;
let _ = ws_stream.next().await;
}
TypingAction::Backspace => {
let down = serde_json::json!({
"id": cmd_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyDown",
"key": "Backspace",
"code": "Backspace",
"windowsVirtualKeyCode": 8,
"nativeVirtualKeyCode": 8,
}
});
cmd_id += 1;
ws_stream
.send(Message::Text(down.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send key event: {e}"),
})?;
let _ = ws_stream.next().await;
let up = serde_json::json!({
"id": cmd_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyUp",
"key": "Backspace",
"code": "Backspace",
"windowsVirtualKeyCode": 8,
"nativeVirtualKeyCode": 8,
}
});
cmd_id += 1;
ws_stream
.send(Message::Text(up.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send key event: {e}"),
})?;
let _ = ws_stream.next().await;
}
}
}
Ok(())
}
/// Send a CDP command and wait for the page to finish loading.
/// Uses a single WebSocket connection to: enable Page events, send the command,
/// wait for the command response, then wait for `Page.loadEventFired`.
async fn send_cdp_and_wait_for_load(
&self,
ws_url: &str,
method: &str,
params: serde_json::Value,
timeout_secs: u64,
) -> Result<serde_json::Value, McpError> {
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect to CDP WebSocket: {e}"),
})?;
// Enable Page domain events so we receive loadEventFired
let enable_cmd = serde_json::json!({
"id": 1,
"method": "Page.enable",
"params": {}
});
ws_stream
.send(Message::Text(enable_cmd.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send Page.enable: {e}"),
})?;
// Wait for Page.enable response
loop {
let msg = ws_stream
.next()
.await
.ok_or_else(|| McpError {
code: -32000,
message: "WebSocket closed waiting for Page.enable response".to_string(),
})?
.map_err(|e| McpError {
code: -32000,
message: format!("CDP WebSocket error: {e}"),
})?;
if let Message::Text(text) = msg {
let resp: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default();
if resp.get("id") == Some(&serde_json::json!(1)) {
break;
}
}
}
// Send the actual command (e.g., Page.navigate)
let command = serde_json::json!({
"id": 2,
"method": method,
"params": params
});
ws_stream
.send(Message::Text(command.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send CDP command: {e}"),
})?;
// Wait for command response and then for Page.loadEventFired
let mut command_result = None;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
// Timed out waiting for load — return the command result if we have it
break;
}
let msg = match tokio::time::timeout(remaining, ws_stream.next()).await {
Ok(Some(Ok(msg))) => msg,
Ok(Some(Err(e))) => {
return Err(McpError {
code: -32000,
message: format!("CDP WebSocket error: {e}"),
});
}
Ok(None) => break, // stream ended
Err(_) => break, // timeout
};
if let Message::Text(text) = msg {
let response: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default();
// Check for command response
if response.get("id") == Some(&serde_json::json!(2)) {
if let Some(error) = response.get("error") {
return Err(McpError {
code: -32000,
message: format!("CDP error: {error}"),
});
}
command_result = Some(
response
.get("result")
.cloned()
.unwrap_or(serde_json::json!({})),
);
}
// Check for Page.loadEventFired — page is fully loaded
if response.get("method") == Some(&serde_json::json!("Page.loadEventFired")) {
break;
}
}
}
// Disable Page domain events
let disable_cmd = serde_json::json!({
"id": 3,
"method": "Page.disable",
"params": {}
});
let _ = ws_stream
.send(Message::Text(disable_cmd.to_string().into()))
.await;
command_result.ok_or_else(|| McpError {
code: -32000,
message: "No response received from CDP".to_string(),
})
}
fn get_running_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" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
});
}
if profile.process_id.is_none() {
return Err(McpError {
code: -32000,
message: format!("Profile '{}' is not running", profile.name),
});
}
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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
self
.send_cdp_and_wait_for_load(
&ws_url,
"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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).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(&ws_url, "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(&ws_url, "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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).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(&ws_url, "Runtime.evaluate", cdp_params, 30)
.await?
} else {
self
.send_cdp(&ws_url, "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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).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(
&ws_url,
"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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).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(
&ws_url,
"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(
&ws_url,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
} else {
self.send_human_keystrokes(&ws_url, 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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).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(
&ws_url,
"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("");
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": content
}]
}))
}
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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let result = self
.send_cdp(
&ws_url,
"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()
}]
}))
}
// --- 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"
}]
}))
}
}
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 41 tools (34 + 7 browser interaction tools)
assert!(tools.len() >= 41);
// 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"));
// 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"));
// 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"));
}
#[test]
fn test_mcp_server_initial_state() {
let server = McpServer::new();
assert!(!server.is_running());
}
}