refactor: cleanup

This commit is contained in:
zhom
2026-07-25 22:59:13 +04:00
parent 59a3e5f2a1
commit b9070693ed
49 changed files with 1204 additions and 2208 deletions
+95 -18
View File
@@ -6,9 +6,9 @@ use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
http::{header, HeaderMap, Method, StatusCode},
middleware::{self, Next},
response::{Json, Response},
response::{IntoResponse, Json, Response},
routing::get,
Router,
};
@@ -494,14 +494,16 @@ impl ApiServer {
);
listener
}
Err(e) => return Err(format!("Failed to bind to any port: {e}")),
Err(e) => {
return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e));
}
}
}
};
let actual_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {e}"))?
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.port();
// Create router with OpenAPI documentation
@@ -538,8 +540,7 @@ impl ApiServer {
let api = ApiDoc::openapi();
let v1_routes = v1_routes
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
// Innermost so only authenticated automation requests consume quota.
.layer(middleware::from_fn(rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
@@ -692,18 +693,47 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
response
}
/// Chokepoint for the future per-hour automation request limit. The limit
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
/// this middleware is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated requests per rolling hour and
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
async fn rate_limit_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
Ok(next.run(request).await)
fn is_automation_request(method: &Method, path: &str) -> bool {
if method != Method::POST {
return false;
}
if matches!(path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop") {
return true;
}
let Some(profile_action) = path.strip_prefix("/v1/profiles/") else {
return false;
};
let mut segments = profile_action.split('/');
matches!(
(segments.next(), segments.next(), segments.next()),
(Some(_), Some("run" | "open-url" | "kill"), None)
)
}
async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response {
if !is_automation_request(request.method(), request.uri().path()) {
return next.run(request).await;
}
match crate::automation_rate_limiter::check_automation_rate_limit().await {
crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
log::warn!(
"[api] Rejected {}: automation rate limit exceeded; retry in {}s",
request.uri().path(),
retry_after_secs
);
(
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry_after_secs.to_string())],
"automation request rate limit exceeded",
)
.into_response()
}
crate::automation_rate_limiter::RateLimitOutcome::Unlimited
| crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await,
}
}
// Global API server instance
@@ -2036,6 +2066,7 @@ async fn delete_extension_group_api(
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is locked by another team member"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2124,6 +2155,7 @@ async fn run_profile(
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "Profile not found"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2165,6 +2197,7 @@ async fn open_url_in_profile(
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan required"),
(status = 404, description = "Profile not found"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2217,6 +2250,7 @@ async fn kill_profile(
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2312,6 +2346,7 @@ async fn batch_run_profiles(
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2672,6 +2707,35 @@ mod tests {
assert!(!is_valid(""));
}
#[test]
fn rate_limit_only_classifies_browser_automation_routes() {
for path in [
"/v1/profiles/profile-id/run",
"/v1/profiles/profile-id/open-url",
"/v1/profiles/profile-id/kill",
"/v1/profiles/batch/run",
"/v1/profiles/batch/stop",
] {
assert!(
is_automation_request(&Method::POST, path),
"automation route was not limited: {path}"
);
}
for (method, path) in [
(Method::GET, "/v1/profiles/profile-id/run"),
(Method::POST, "/v1/profiles"),
(Method::POST, "/v1/profiles/import"),
(Method::GET, "/v1/profiles"),
(Method::GET, "/openapi.json"),
] {
assert!(
!is_automation_request(&method, path),
"free or non-mutating route was limited: {method} {path}"
);
}
}
fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
spec["components"]["schemas"][schema]["required"]
.as_array()
@@ -2764,5 +2828,18 @@ mod tests {
!paths.keys().any(|p| p.contains("wayfern-token")),
"wayfern-token endpoints were removed and must stay out of the spec"
);
for path in [
"/v1/profiles/{id}/run",
"/v1/profiles/{id}/open-url",
"/v1/profiles/{id}/kill",
"/v1/profiles/batch/run",
"/v1/profiles/batch/stop",
] {
assert!(
paths[path]["post"]["responses"].get("429").is_some(),
"automation route is missing its 429 response: {path}"
);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use crate::cloud_auth::CLOUD_AUTH;
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitOutcome {
Unlimited,
Allowed { remaining: u64 },
Limited { retry_after_secs: u64 },
}
#[derive(Default)]
struct AutomationRateLimiter {
requests: HashMap<String, VecDeque<Instant>>,
}
impl AutomationRateLimiter {
fn check_at(&mut self, identity: &str, requests_per_hour: u64, now: Instant) -> RateLimitOutcome {
if requests_per_hour == 0 {
return RateLimitOutcome::Unlimited;
}
self.requests.retain(|_, requests| {
while requests
.front()
.is_some_and(|started| now.duration_since(*started) >= RATE_LIMIT_WINDOW)
{
requests.pop_front();
}
!requests.is_empty()
});
let requests = self.requests.entry(identity.to_string()).or_default();
if requests.len() as u64 >= requests_per_hour {
let retry_after_secs = requests
.front()
.map(|started| {
let remaining = RATE_LIMIT_WINDOW.saturating_sub(now.duration_since(*started));
remaining
.as_secs()
.saturating_add(u64::from(remaining.subsec_nanos() > 0))
.max(1)
})
.unwrap_or(1);
return RateLimitOutcome::Limited { retry_after_secs };
}
requests.push_back(now);
RateLimitOutcome::Allowed {
remaining: requests_per_hour.saturating_sub(requests.len() as u64),
}
}
}
static AUTOMATION_RATE_LIMITER: LazyLock<Mutex<AutomationRateLimiter>> =
LazyLock::new(|| Mutex::new(AutomationRateLimiter::default()));
pub async fn check_automation_rate_limit() -> RateLimitOutcome {
let Some((identity, requests_per_hour)) = CLOUD_AUTH.automation_rate_limit().await else {
return RateLimitOutcome::Unlimited;
};
AUTOMATION_RATE_LIMITER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.check_at(&identity, requests_per_hour, Instant::now())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rolling_window_limits_per_identity_and_recovers() {
let mut limiter = AutomationRateLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.check_at("user-a", 2, now),
RateLimitOutcome::Allowed { remaining: 1 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + Duration::from_secs(1)),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + Duration::from_secs(2)),
RateLimitOutcome::Limited {
retry_after_secs: 3598
}
);
assert_eq!(
limiter.check_at("user-b", 2, now + Duration::from_secs(2)),
RateLimitOutcome::Allowed { remaining: 1 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + RATE_LIMIT_WINDOW),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at(
"user-a",
2,
now + RATE_LIMIT_WINDOW + Duration::from_secs(1)
),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at(
"user-a",
2,
now + RATE_LIMIT_WINDOW * 2 + Duration::from_secs(1)
),
RateLimitOutcome::Allowed { remaining: 1 }
);
}
#[test]
fn zero_limit_is_unlimited_and_does_not_consume_capacity() {
let mut limiter = AutomationRateLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.check_at("user-a", 0, now),
RateLimitOutcome::Unlimited
);
assert_eq!(
limiter.check_at("user-a", 1, now),
RateLimitOutcome::Allowed { remaining: 0 }
);
}
}
+19 -154
View File
@@ -23,8 +23,7 @@ pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
/// rate-limit chokepoints in api_server / mcp_server.
/// backend's DEFAULT_REQUESTS_PER_HOUR.
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
@@ -828,14 +827,24 @@ impl CloudAuthManager {
}
}
/// Per-hour cap on automation requests (0 when automation is unavailable).
/// Carried for the future local rate limiter; read by the inert chokepoints.
pub async fn requests_per_hour(&self) -> i64 {
self
.entitlements()
.await
.map(|e| e.requests_per_hour)
.unwrap_or(0)
/// Identity and positive per-hour cap for the shared REST/MCP automation
/// limiter. No active automation entitlement means no limiter entry; the
/// capability gates still reject paid operations independently.
pub async fn automation_rate_limit(&self) -> Option<(String, u64)> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled() {
if let Ok(limit) = std::env::var("DONUT_E2E_REQUESTS_PER_HOUR") {
if let Ok(limit) = limit.parse::<u64>() {
if limit > 0 {
return Some(("e2e-automation".to_string(), limit));
}
}
}
}
let state = self.get_user().await?;
let limit = state.user.entitlements().requests_per_hour;
(limit > 0).then_some((state.user.id, limit as u64))
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
@@ -1040,126 +1049,6 @@ impl CloudAuthManager {
.await
}
/// Fetch region list for a country from the cloud backend
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
country
);
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch cities: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Cities fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse cities: {e}"))
}
})
.await
}
/// Fetch ISP list for a country, optionally filtered by region and city
pub async fn fetch_isps(
&self,
country: &str,
region: Option<&str>,
city: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
let city = city.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
if let Some(ref c) = city {
url.push_str(&format!("&city={}", c));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("ISPs fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse ISPs: {e}"))
}
})
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
@@ -1459,30 +1348,6 @@ pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(
country: String,
region: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
}
#[tauri::command]
pub async fn cloud_get_isps(
country: String,
region: Option<String>,
city: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH
.fetch_isps(&country, region.as_deref(), city.as_deref())
.await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
+53 -24
View File
@@ -15,6 +15,14 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
pub(crate) fn backend_error(code: &str) -> String {
serde_json::json!({ "code": code }).to_string()
}
pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Display) -> String {
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
@@ -39,6 +47,7 @@ mod api_server;
mod app_auto_updater;
pub mod app_dirs;
mod auto_updater;
mod automation_rate_limiter;
mod browser;
mod browser_runner;
mod browser_version_manager;
@@ -271,15 +280,14 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
Ok(())
}
/// Prefix a command error with context, but pass structured `{"code": ...}`
/// backend errors through untouched the frontend can only translate a code
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
/// Preserve structured backend errors and wrap lower-level diagnostics in the
/// generic structured error shape expected by the frontend.
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
let msg = e.to_string();
if msg.starts_with('{') {
msg
} else {
format!("{context}: {msg}")
backend_error_with_detail("INTERNAL_ERROR", format!("{context}: {msg}"))
}
}
@@ -531,14 +539,14 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig
let port = mcp_server
.get_port()
.ok_or("MCP server port not available")?;
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
Ok(Some(McpConfig { port, token }))
}
@@ -739,13 +747,15 @@ fn update_claude_extensions_registry(
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let port = mcp_server
.get_port()
.ok_or_else(|| backend_error("MCP_SERVER_NOT_RUNNING"))?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
@@ -761,24 +771,28 @@ async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
return Err(backend_error("MCP_AGENT_UNKNOWN"));
}
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
let result = if agent_id == "claude-desktop" {
add_mcp_to_claude_desktop_internal(&app_handle).await
} else {
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
};
result.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e))
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
return Err(backend_error("MCP_AGENT_UNKNOWN"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
let result = if agent_id == "claude-desktop" {
remove_mcp_from_claude_desktop_internal()
} else {
mcp_integrations::uninstall_generic(&agent_id)
};
result.map_err(|e| backend_error_with_detail("MCP_AGENT_REMOVE_FAILED", e))
}
#[tauri::command]
@@ -2432,9 +2446,6 @@ pub fn run_with_builder(
cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage,
cloud_auth::cloud_get_countries,
cloud_auth::cloud_get_regions,
cloud_auth::cloud_get_cities,
cloud_auth::cloud_get_isps,
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service,
cloud_auth::cloud_get_wayfern_token,
@@ -2481,6 +2492,24 @@ pub fn run_with_builder(
mod tests {
use std::fs;
#[test]
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
let coded = super::backend_error("PROFILE_NOT_FOUND");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&coded).unwrap()["code"],
"PROFILE_NOT_FOUND"
);
assert_eq!(super::wrap_backend_error(&coded, "ignored"), coded);
let wrapped = super::wrap_backend_error("disk unavailable", "Failed to save");
let parsed = serde_json::from_str::<serde_json::Value>(&wrapped).unwrap();
assert_eq!(parsed["code"], "INTERNAL_ERROR");
assert_eq!(
parsed["params"]["detail"],
"Failed to save: disk unavailable"
);
}
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
+117 -44
View File
@@ -184,19 +184,17 @@ impl McpServer {
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(),
);
return Err(crate::backend_error("WAYFERN_TERMS_REQUIRED"));
}
if self.is_running() {
return Err("MCP server is already running".to_string());
return Err(crate::backend_error("MCP_SERVER_ALREADY_RUNNING"));
}
let settings_manager = SettingsManager::instance();
let settings = settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
// Get or generate token
let existing_token = settings_manager
@@ -211,12 +209,16 @@ impl McpServer {
settings_manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
};
// Determine port (use saved port, or try default, or random)
let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT);
let actual_port = self.bind_to_available_port(preferred_port).await?;
let listener = self.bind_to_available_port(preferred_port).await?;
let actual_port = listener
.local_addr()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.port();
// Save port if it changed
if settings.mcp_port != Some(actual_port) {
@@ -224,7 +226,7 @@ impl McpServer {
new_settings.mcp_port = Some(actual_port);
settings_manager
.save_settings(&new_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
}
// Store state
@@ -244,31 +246,31 @@ impl McpServer {
server: McpServer::instance(),
token,
};
tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx));
tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx));
log::info!("[mcp] Server started on port {}", actual_port);
Ok(actual_port)
}
async fn bind_to_available_port(&self, preferred: u16) -> Result<u16, String> {
async fn bind_to_available_port(&self, preferred: u16) -> Result<TcpListener, String> {
let addr = SocketAddr::from(([127, 0, 0, 1], preferred));
if TcpListener::bind(addr).await.is_ok() {
return Ok(preferred);
if let Ok(listener) = TcpListener::bind(addr).await {
return Ok(listener);
}
for _ in 0..10 {
let port = 51000 + (rand::random::<u16>() % 1000);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if TcpListener::bind(addr).await.is_ok() {
return Ok(port);
if let Ok(listener) = TcpListener::bind(addr).await {
return Ok(listener);
}
}
Err("Could not find available port for MCP server".to_string())
Err(crate::backend_error("MCP_PORT_UNAVAILABLE"))
}
async fn run_http_server(
port: u16,
listener: TcpListener,
state: McpHttpState,
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
@@ -286,28 +288,17 @@ impl McpServer {
.delete(Self::handle_mcp_delete),
)
.route("/health", get(Self::handle_health))
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(Self::rate_limit_middleware))
.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);
}
let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0);
let server = async move {
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
if let Err(e) = axum::serve(listener, app).await {
log::error!("[mcp] Server error: {}", e);
}
};
@@ -319,17 +310,6 @@ impl McpServer {
}
}
/// Chokepoint for the future per-hour automation request limit, mirroring the
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
/// entitlements; this is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated tool calls per rolling hour
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
Ok(next.run(req).await)
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
@@ -476,14 +456,65 @@ impl McpServer {
}
}
if Self::is_automation_tool_call(&request) {
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
crate::automation_rate_limiter::check_automation_rate_limit().await
{
log::warn!(
"[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s",
retry_after_secs
);
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry_after_secs.to_string())],
"automation request rate limit exceeded",
)
.into_response();
}
}
let response = state.server.handle_request(request).await;
Json(response).into_response()
}
}
fn is_automation_tool_call(request: &McpRequest) -> bool {
if request.method != "tools/call" {
return false;
}
let Some(tool_name) = request
.params
.as_ref()
.and_then(|params| params.get("name"))
.and_then(|name| name.as_str())
else {
return false;
};
matches!(
tool_name,
"run_profile"
| "kill_profile"
| "batch_run_profiles"
| "batch_stop_profiles"
| "start_sync_session"
| "navigate"
| "screenshot"
| "evaluate_javascript"
| "click_element"
| "type_text"
| "get_page_content"
| "get_page_info"
| "get_interactive_elements"
| "click_by_index"
| "type_by_index"
)
}
pub async fn stop(&self) -> Result<(), String> {
if !self.is_running() {
return Err("MCP server is not running".to_string());
return Err(crate::backend_error("MCP_SERVER_NOT_RUNNING"));
}
let mut inner = self.inner.lock().await;
@@ -5528,4 +5559,46 @@ mod tests {
let server = McpServer::new();
assert!(!server.is_running());
}
#[test]
fn rate_limit_only_classifies_browser_automation_tools() {
let request = |method: &str, name: Option<&str>| McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: method.to_string(),
params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })),
};
for name in [
"run_profile",
"kill_profile",
"batch_run_profiles",
"batch_stop_profiles",
"start_sync_session",
"navigate",
"screenshot",
"evaluate_javascript",
"click_element",
"type_text",
"get_page_content",
"get_page_info",
"get_interactive_elements",
"click_by_index",
"type_by_index",
] {
assert!(
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
"automation tool was not limited: {name}"
);
}
assert!(!McpServer::is_automation_tool_call(&request(
"tools/call",
Some("list_profiles")
)));
assert!(!McpServer::is_automation_tool_call(&request(
"tools/list",
None
)));
}
}