mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 15:26:05 +02:00
4473 lines
133 KiB
Rust
4473 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-11-25";
|
|
const SERVER_NAME: &str = "donut-browser";
|
|
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct McpResponse {
|
|
jsonrpc: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
id: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
result: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<McpError>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct McpError {
|
|
code: i32,
|
|
message: String,
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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/{token}",
|
|
post(Self::handle_mcp_post)
|
|
.get(Self::handle_mcp_get)
|
|
.delete(Self::handle_mcp_delete),
|
|
)
|
|
.route(
|
|
"/mcp",
|
|
post(Self::handle_mcp_post)
|
|
.get(Self::handle_mcp_get)
|
|
.delete(Self::handle_mcp_delete),
|
|
)
|
|
.route("/health", get(Self::handle_health))
|
|
.layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
Self::auth_middleware,
|
|
))
|
|
.with_state(state);
|
|
|
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
|
|
|
let server = async {
|
|
match TcpListener::bind(addr).await {
|
|
Ok(listener) => {
|
|
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
|
|
if let Err(e) = axum::serve(listener, app).await {
|
|
log::error!("[mcp] Server error: {}", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[mcp] Failed to bind on port {}: {}", port, e);
|
|
}
|
|
}
|
|
};
|
|
|
|
tokio::select! {
|
|
_ = server => {},
|
|
_ = shutdown_rx => {
|
|
log::info!("[mcp] Server shutting down");
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn auth_middleware(
|
|
State(state): State<McpHttpState>,
|
|
req: Request<Body>,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
let path = req.uri().path();
|
|
|
|
if path == "/health" {
|
|
return Ok(next.run(req).await);
|
|
}
|
|
|
|
// Check token from URL path: /mcp/{token}
|
|
let path_token = path
|
|
.strip_prefix("/mcp/")
|
|
.filter(|t| !t.is_empty() && !t.contains('/'));
|
|
|
|
// Check token from Authorization header
|
|
let header_token = req
|
|
.headers()
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|h| h.to_str().ok())
|
|
.and_then(|h| h.strip_prefix("Bearer "));
|
|
|
|
let valid =
|
|
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
|
|
|
if !valid {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
Ok(next.run(req).await)
|
|
}
|
|
|
|
async fn handle_health() -> impl IntoResponse {
|
|
Json(serde_json::json!({
|
|
"status": "ok",
|
|
"server": SERVER_NAME,
|
|
"version": SERVER_VERSION,
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
}))
|
|
}
|
|
|
|
async fn handle_mcp_get() -> impl IntoResponse {
|
|
// We don't support server-initiated SSE streams
|
|
StatusCode::METHOD_NOT_ALLOWED
|
|
}
|
|
|
|
async fn handle_mcp_delete(
|
|
State(state): State<McpHttpState>,
|
|
req: Request<Body>,
|
|
) -> impl IntoResponse {
|
|
let session_id = req
|
|
.headers()
|
|
.get("mcp-session-id")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
if let Some(sid) = session_id {
|
|
let mut inner = state.server.inner.lock().await;
|
|
inner.sessions.remove(&sid);
|
|
log::info!("[mcp] Session terminated: {}", sid);
|
|
}
|
|
|
|
StatusCode::OK
|
|
}
|
|
|
|
async fn handle_mcp_post(State(state): State<McpHttpState>, req: Request<Body>) -> Response {
|
|
let session_id = req
|
|
.headers()
|
|
.get("mcp-session-id")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
let body_bytes = match axum::body::to_bytes(req.into_body(), 1024 * 1024).await {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_REQUEST, "Invalid request body").into_response();
|
|
}
|
|
};
|
|
|
|
let request: McpRequest = match serde_json::from_slice(&body_bytes) {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response();
|
|
}
|
|
};
|
|
|
|
let is_notification = request.id.is_none();
|
|
let method = request.method.clone();
|
|
|
|
// Handle initialize (no session required)
|
|
if method == "initialize" {
|
|
let response = state.server.handle_initialize(request).await;
|
|
match response {
|
|
Ok((session_id, result)) => {
|
|
let body = McpResponse {
|
|
jsonrpc: "2.0".to_string(),
|
|
id: Some(result.0),
|
|
result: Some(result.1),
|
|
error: None,
|
|
};
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header("mcp-session-id", &session_id)
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
|
.unwrap()
|
|
}
|
|
Err((id, error)) => {
|
|
let body = McpResponse {
|
|
jsonrpc: "2.0".to_string(),
|
|
id: Some(id),
|
|
result: None,
|
|
error: Some(error),
|
|
};
|
|
Json(body).into_response()
|
|
}
|
|
}
|
|
} else if is_notification {
|
|
// Notifications (like notifications/initialized) -> 202 Accepted
|
|
if method == "notifications/initialized" {
|
|
if let Some(sid) = &session_id {
|
|
let mut inner = state.server.inner.lock().await;
|
|
if let Some(session) = inner.sessions.get_mut(sid) {
|
|
session.initialized = true;
|
|
}
|
|
}
|
|
}
|
|
StatusCode::ACCEPTED.into_response()
|
|
} else {
|
|
// Validate session exists
|
|
if let Some(sid) = &session_id {
|
|
let inner = state.server.inner.lock().await;
|
|
if !inner.sessions.contains_key(sid) {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|