From 149ae812467871931120896d4e342876ff1d42a9 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 11 Jan 2026 03:59:00 +0400 Subject: [PATCH] feat: add mcp --- src-tauri/src/api_server.rs | 23 +- src-tauri/src/downloader.rs | 5 + src-tauri/src/lib.rs | 61 +++- src-tauri/src/mcp_server.rs | 557 ++++++++++++++++++++++++++++++++++++ 4 files changed, 641 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/mcp_server.rs diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 62642c5..797fdbb 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -322,10 +322,12 @@ impl ApiServer { let api = ApiDoc::openapi(); - let v1_routes = v1_routes.layer(middleware::from_fn_with_state( - state.clone(), - auth_middleware, - )); + let v1_routes = v1_routes + .layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )) + .layer(middleware::from_fn(terms_check_middleware)); let app = Router::new() .nest("/v1", v1_routes) @@ -363,6 +365,19 @@ impl ApiServer { } } +// Terms and Conditions check middleware +async fn terms_check_middleware( + request: axum::extract::Request, + next: Next, +) -> Result { + // Check if Wayfern terms have been accepted + if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() { + return Err(StatusCode::FORBIDDEN); + } + + Ok(next.run(request).await) +} + // Authentication middleware async fn auth_middleware( State(state): State, diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 227371e..62057d7 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -635,6 +635,11 @@ impl Downloader { browser_str: String, version: String, ) -> Result> { + // Check if Wayfern terms have been accepted before allowing any browser downloads + if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() { + return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into()); + } + // Check if this browser-version pair is already being downloaded let download_key = format!("{browser_str}-{version}"); { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cabb836..8636b32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,8 +34,11 @@ mod settings_manager; pub mod sync; pub mod traffic_stats; mod wayfern_manager; +mod wayfern_terms; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme +mod commercial_license; mod cookie_manager; +mod mcp_server; mod tag_manager; mod version_updater; @@ -235,6 +238,54 @@ async fn copy_profile_cookies( cookie_manager::CookieManager::copy_cookies(&app_handle, request).await } +#[tauri::command] +fn check_wayfern_terms_accepted() -> bool { + wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() +} + +#[tauri::command] +async fn accept_wayfern_terms() -> Result<(), String> { + wayfern_terms::WayfernTermsManager::instance() + .accept_terms() + .await +} + +#[tauri::command] +async fn get_commercial_trial_status( + app_handle: tauri::AppHandle, +) -> Result { + commercial_license::CommercialLicenseManager::instance() + .get_trial_status(&app_handle) + .await +} + +#[tauri::command] +async fn acknowledge_trial_expiration(app_handle: tauri::AppHandle) -> Result<(), String> { + commercial_license::CommercialLicenseManager::instance() + .acknowledge_expiration(&app_handle) + .await +} + +#[tauri::command] +fn has_acknowledged_trial_expiration(app_handle: tauri::AppHandle) -> Result { + commercial_license::CommercialLicenseManager::instance().has_acknowledged(&app_handle) +} + +#[tauri::command] +async fn start_mcp_server(app_handle: tauri::AppHandle) -> Result<(), String> { + mcp_server::McpServer::instance().start(app_handle).await +} + +#[tauri::command] +async fn stop_mcp_server() -> Result<(), String> { + mcp_server::McpServer::instance().stop().await +} + +#[tauri::command] +fn get_mcp_server_status() -> bool { + mcp_server::McpServer::instance().is_running() +} + #[tauri::command] async fn is_geoip_database_available() -> Result { Ok(GeoIPDownloader::is_geoip_database_available()) @@ -841,7 +892,15 @@ pub fn run() { is_proxy_in_use_by_synced_profile, is_group_in_use_by_synced_profile, read_profile_cookies, - copy_profile_cookies + copy_profile_cookies, + check_wayfern_terms_accepted, + accept_wayfern_terms, + get_commercial_trial_status, + acknowledge_trial_expiration, + has_acknowledged_trial_expiration, + start_mcp_server, + stop_mcp_server, + get_mcp_server_status ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs new file mode 100644 index 0000000..02bf1f2 --- /dev/null +++ b/src-tauri/src/mcp_server.rs @@ -0,0 +1,557 @@ +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tauri::AppHandle; +use tokio::sync::Mutex as AsyncMutex; + +use crate::profile::{BrowserProfile, ProfileManager}; +use crate::proxy_manager::PROXY_MANAGER; +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: serde_json::Value, + method: String, + params: Option, +} + +#[derive(Debug, Serialize)] +pub struct McpResponse { + jsonrpc: String, + id: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +pub struct McpError { + code: i32, + message: String, +} + +struct McpServerInner { + app_handle: Option, +} + +pub struct McpServer { + inner: Arc>, + is_running: AtomicBool, +} + +impl McpServer { + fn new() -> Self { + Self { + inner: Arc::new(AsyncMutex::new(McpServerInner { app_handle: None })), + is_running: AtomicBool::new(false), + } + } + + pub fn instance() -> &'static McpServer { + &MCP_SERVER + } + + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + pub async fn start(&self, app_handle: AppHandle) -> Result<(), String> { + // Check terms acceptance first + 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 mut inner = self.inner.lock().await; + inner.app_handle = Some(app_handle); + self.is_running.store(true, Ordering::SeqCst); + + log::info!("MCP server started"); + Ok(()) + } + + 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; + self.is_running.store(false, Ordering::SeqCst); + + log::info!("MCP server stopped"); + Ok(()) + } + + pub fn get_tools(&self) -> Vec { + vec![ + McpTool { + name: "list_profiles".to_string(), + description: "List all Wayfern 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: "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"] + }), + }, + ] + } + + pub async fn handle_request(&self, request: McpRequest) -> McpResponse { + if !self.is_running() { + return McpResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: None, + error: Some(McpError { + code: -32001, + message: "MCP server is not running".to_string(), + }), + }; + } + + let result = match request.method.as_str() { + "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: request.id, + result: Some(value), + error: None, + }, + Err(error) => McpResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: None, + error: Some(error), + }, + } + } + + async fn handle_tools_list(&self) -> Result { + Ok(serde_json::json!({ + "tools": self.get_tools() + })) + } + + async fn handle_tool_call( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| McpError { + code: -32602, + message: "Missing parameters".to_string(), + })?; + + let tool_name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing tool name".to_string(), + })?; + + let arguments = params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + match tool_name { + "list_profiles" => self.handle_list_profiles().await, + "get_profile" => self.handle_get_profile(&arguments).await, + "run_profile" => self.handle_run_profile(&arguments).await, + "kill_profile" => self.handle_kill_profile(&arguments).await, + "list_proxies" => self.handle_list_proxies().await, + "get_profile_status" => self.handle_get_profile_status(&arguments).await, + _ => Err(McpError { + code: -32602, + message: format!("Unknown tool: {tool_name}"), + }), + } + } + + async fn handle_list_profiles(&self) -> Result { + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + // Filter to only Wayfern 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 { + 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 { + 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(), + }); + } + + // 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 { + 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}"), + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Browser profile '{}' stopped successfully", profile.name) + }] + })) + } + + async fn handle_list_proxies(&self) -> Result { + let proxies = PROXY_MANAGER.get_stored_proxies(); + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&proxies).unwrap_or_default() + }] + })) + } + + async fn handle_get_profile_status( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_id".to_string(), + })?; + + // Get the profile + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| McpError { + code: -32000, + message: format!("Profile not found: {profile_id}"), + })?; + + // Check if it's a Wayfern 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() + }] + })) + } +} + +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 these tools + assert!(tools.len() >= 5); + + // Check tool names + let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + 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(&"list_proxies")); + } + + #[test] + fn test_mcp_server_initial_state() { + let server = McpServer::new(); + assert!(!server.is_running()); + } +}